Deploying the CMS
Deploy AstroCraft as one container holding a real clone, plus two branch builds of your public site. Notes for Dokploy, Coolify and Railway.
Knowing which of two things you are deploying is most of this page.
One container runs the admin. It holds a real, full clone of your repository, checked out on staging, because the CMS is a git layer: a save is a commit, a landed commit auto-pushes staging, and Publish fast-forwards your publish branch. Point cms.<your-domain> at it — the invite and verification links the CMS mails are built on that host.
Two branch builds are your public site, on whatever static platform you already use. The publish branch with no flags is production. The staging branch built with PREVIEW=1 is the preview that shows drafts. Both rebuild on push, which is why the CMS needs no deploy API: publishing is a fast-forward of the branch production watches.
The target shape is the self-hosted PaaS row — Dokploy, Coolify, Railway — which are the same thing wearing three UIs: build a Docker image, attach a persistent volume, inject environment.
Once, before the first boot
Create the staging branch if you do not have one. The clone asks for it by name:
git push origin main:staging
Mint the push credential: a fine-grained PAT scoped to this one repository with Contents: read and write. That is the one form all three platforms share. An SSH deploy key works instead.
If invites and sign-up verification should mail, have your Resend key and a verified sender ready.
The container
Build from the repository’s Dockerfile. The image holds the toolchain and an entrypoint; the checkout lives on the volume at /data/site, so unpushed commits and the accounts store survive every redeploy together.
First boot clones and builds — give it a few minutes. Later boots reuse the clone and rebuild.
| Variable | Required | What it is |
|---|---|---|
GIT_URL |
yes | your repository’s HTTPS clone URL |
GIT_PUSH_TOKEN |
one of these two | the fine-grained PAT |
GIT_SSH_COMMAND |
e.g. ssh -i /run/secrets/deploy_key, with an SSH GIT_URL |
|
GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL |
yes | the deployment’s committer identity |
RESEND_API_KEY / MAIL_FROM |
no | the two mail paths |
GIT_BRANCH |
no (default staging) |
the branch the checkout lives on |
ASTROCRAFT_DB_URL / ASTROCRAFT_DB_TOKEN |
no | move the store into a database |
PORT / HOST |
no (4321 / 0.0.0.0) | the standalone server |
Commits are authored by the signed-in account and committed by that identity, which is what keeps git log and git blame worth reading.
Attach a volume at /data. Map your cms. subdomain to the container’s port. Create the first admin with the accounts CLI inside the container, or sign up and promote.
Run that CLI from inside the checkout (
/data/site). The.accounts/store is resolved against the process’s working directory, so running it from/writes an account into a store the server will never read — and the server will still offer first-admin sign-up. The entrypoint starts the server inside the checkout for the same reason.
The push credential reaches git through the environment, never through argv and never written into .git/config.
The public site’s two builds
Configure two deploys of the same repository:
- Publish branch, plain
pnpm build→ production. Pointworkspace.domainat it. - Staging branch, same command plus
PREVIEW=1→ the preview. Paste its URL into Settings → Staging preview.
Nothing to configure for the live check, as long as your / page carries the build stamp.
What lives where
Two facts are deliberately not environment variables: the publish branch and the staging preview URL. Both are settings an admin sets on screen, living on the volume with everything else. A deployment restart must not be the way you change which branch you publish to.
With ASTROCRAFT_DB_URL set, the volume holds only the checkout — which the entrypoint re-clones anyway — so the machine becomes disposable. See Bring a database.
The platforms
Dokploy — an Application from the git repository, build type Dockerfile; env from the table; a Volume Mount at /data; the cms. domain on port 4321. The two public deploys are two more applications watching main and staging, the second with PREVIEW=1.
Coolify — new resource → Dockerfile from the repository; env from the table; Persistent Storage at /data; domain on 4321. Branch builds as two Static resources with the same split.
Railway — new service from the repo (it detects the Dockerfile); variables from the table; a Volume at /data; a domain on 4321. Branch builds via two more services or your static host.
The fresh-machine check
From nothing but the repository URL, this is the loop closing:
- The container boots and
cms.<domain>/admin/signs in. - A save commits — the entry’s History tab shows it — and the commit reaches the remote on its own within a few seconds. The site panel’s unpushed count drains.
- The staging deploy shows the draft and production does not.
- Publish fast-forwards the publish branch, and “Your changes are live ✓” appears when the production build lands.
- After a forced redeploy of the container, the same account signs straight back in and the settings read what was chosen — the store survived.
When that closes on one platform, the deployment is done.
Troubleshooting
Clone fails with Remote branch staging not found. The one-time git push origin main:staging above.
Publish answers “no identity”. GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL are unset.
A push is rejected. The next step is on screen — Update from the site’s repository. Only genuine divergence, where both sides hold commits the other lacks, still needs a terminal.
The container’s own copy of the public site is stale. It rebuilds at boot, not on push. If you serve the public site from the container rather than from branch builds, restart it on publish — all three platforms can watch the publish branch — or accept that its public copy trails until the next boot. The admin, the previews and the live check are unaffected either way.
Everybody was logged out by a redeploy, and settings reset. The volume did not survive, or is not mounted at /data. The store lives inside the checkout unless you moved it to a database.
The build fails inside the publish gate but works locally. The gate builds into a throwaway directory under node_modules/, deliberately — a build written to /tmp fails, because Astro runs its prerender entry from inside the output directory and Node then walks up looking for node_modules and finds none.