Deployment
Urbic builds to output: "server" with @astrojs/node, and that is not a preference. The CMS’s session guard is middleware, and middleware runs at request time only for an on-demand route — for a prerendered one it runs at build time and never sees a visitor. A statically built admin would ship its HTML, drafts and all, straight past the guard while looking perfect. adminPackage refuses the build under output: "static" and names the route it refused for.
The site itself is still static. Every page under src/pages/ carries export const prerender = true except contact.astro, which needs Astro.getActionResult for the enquiry form. So a build emits dist/client/ — 31 prerendered HTML files and the assets — beside dist/server/, which serves /contact/ and the admin.
What the container needs
If you are deploying with the CMS, the usual multi-stage Docker trick — build, then copy only dist/ into a slim runtime — is wrong for this app. The CMS edits the site by writing into src/data/ and src/assets/, committing, and pushing. The running container is a checkout. It needs the source tree, .git, and node_modules, because Publish runs your build command in place. Strip those and every screen that reads git state breaks while the public site still looks fine.
The Dockerfile in the repo is therefore one stage, on Debian rather than Alpine (sharp’s prebuilt binaries here are the glibc ones, and there is no source build to fall back on). It installs git and ca-certificates as runtime dependencies, enables corepack, installs with --frozen-lockfile, copies everything including .git, and runs pnpm build.
Four things it bakes in are worth reading before your first deploy:
SITE_URL is a build arg, not a runtime variable. Thirty-one pages prerender, so the canonical URLs, OG tags and JSON-LD in them are fixed at build time and no runtime environment variable can change them. Edit the ARG SITE_URL default or pass --build-arg SITE_URL=… if your host forwards them. DEPLOY_ENV=production arms the placeholder gate.
ASTROCRAFT_DB_URL defaults to file:/data/astrocraft.db, with /data as the persistent volume. It is read at config load, so it has to be set for the build as well as the run — an image built with it and run without it throws on the first session rather than quietly logging nobody in.
GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL are set because git refuses to commit at all without an identity. Each content commit’s author is the signed-in CMS account (git commit --author=…); the committer is the server. Change the placeholder email to something real for your studio.
HOST=0.0.0.0 and PORT=4321 so a reverse proxy can reach the standalone server, which boots with node ./dist/server/entry.mjs — the same thing astro preview runs.
.dockerignore lists only paths git already ignores. Anything tracked that fails to reach the image shows up as a deletion in git status, and the CMS refuses to sync a dirty tree.
Deploying the CMS
The container above is the shape. Deploying the CMS covers the rest of what a running admin needs — the persistent volume, the git credentials its push uses, the mail sender, and the settings an operator fills in on first boot. Environment variables is the complete list, and Updating is the procedure for replacing src/admin/, src/components/primitives/ and src/components/svg/ without touching your own files.
Two things are worth deciding before the first deploy rather than after. The publish branch in src/admin.config.ts is main by default, so Publish pushes straight to production; point it at a branch your host does not auto-deploy if you would rather a human merged. And the build command the publish gate runs is a setting on the admin’s Settings screen — it runs in the checkout as a shell sentence through sh -c, so pnpm i --frozen-lockfile && pnpm build is as valid as pnpm build.
Deploying without the CMS
If you delete src/admin/ (see Project Structure), the site still needs output: "server" for /contact/, or you can restore prerender = true on that page, drop contactServer from src/actions/index.ts, and go fully static — the form keeps rendering, it simply posts nowhere.
Keeping the one on-demand route, the adapter is a one-line swap. @astrojs/cloudflare, @astrojs/netlify and @astrojs/vercel are drop-in replacements for the adapter: node({ mode: "standalone" }) line. The rest of the config does not change.
Either way, set SITE_URL in the host’s build environment. It is the single value that fixes canonical URLs, OG tags, JSON-LD, the sitemap, robots.txt and llms.txt together.
The pre-deploy list
SITE_URL— your production domain, in the host’s build environment.public/og.jpg— replace the placeholder with a real 1200×630 social image.src/config/siteData.json.ts— name, author,sameAs, and the four contact values.src/config/legalData.json.ts— the terms and privacy copy are placeholders, and they are not legal advice.- Favicons —
public/favicon.svgandpublic/favicon.ico. - Delete the dev catalog —
src/components/Sections/UiCatalog/andsrc/pages/examples/— once you have finished picking primitives. It builds no pages in production, but Tailwind still scans its markup: the shared stylesheet goes from 164,378 to 145,575 bytes and its@keyframesfrom 108 to 44. - If the CMS is coming with you: a persistent volume, a real committer identity,
MAIL_FROM, and a licence key if anyone will publish from the admin rather than from a terminal.
Then run the chain — pnpm lint && pnpm check && pnpm build && pnpm test && pnpm wiki:lint — and ship.