Docker
The bundled Dockerfile and entrypoint explained, running with compose on a VPS, and what the container needs to survive a redeploy.
The templates ship a Dockerfile, a docker-entrypoint.sh and a
docker-compose.prod.yml. Every other guide here is a wrapper around them, so this is
the page that explains what is actually happening — useful whether you're on a VPS, on
Fly, on Kubernetes, or debugging a one-click deploy that came up wrong.
Run it
docker build -t my-aphex .
docker run -d --name aphex -p 3000:3000 \
-v aphex_data:/data \
-e AUTH_SECRET="$(openssl rand -base64 48)" \
-e AUTH_URL=https://cms.example.com \
-e APHEX_SQLITE_URL=file:/data/aphex.db \
-e APHEX_UPLOADS_DIR=/data/uploads \
-e APHEX_EMBEDDED_WORKER=true \
my-aphexThat is a complete production deploy: one container, one volume, no database service.
The volume holds both the SQLite file and the uploaded media, which is why -v is not
optional — without it, a docker run of the next image starts with an empty CMS.
Or with compose, which is the same thing with the variables written down:
docker compose -f docker-compose.prod.yml up -d --builddocker-compose.yml (no suffix) is the development stack — Postgres and Mailpit for pnpm dev, with the app running on your machine. It is not a production file and does not build the app
at all. The production one is docker-compose.prod.yml.
The build
Multi-stage, single-package, pnpm via corepack:
FROM node:22-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml* svelte.config.js vite.config.ts tsconfig.json ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN ADAPTER=node pnpm build
RUN pnpm prune --prod
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/drizzle ./drizzle
EXPOSE 3000
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]The runtime stage also removes the build toolchain and declares a HEALTHCHECK (both
elided above). pnpm prune --prod does not remove the toolchain on its own, because
it is reachable from production dependencies as peers: bits-ui and better-auth
peer-depend on @sveltejs/kit, which peer-depends on Vite and TypeScript. So the
runtime stage deletes them explicitly, along with Sharp's glibc libvips (the image is
Alpine; only the musl build is ever loaded).
If you extend that removal list, boot the image — don't just build it. @sveltejs/kit looks
removable and is not: @aphexcms/cms-core stays external to the server bundle, so its imports
resolve from node_modules at boot, and it imports Kit for redirect/json/error. Delete it
and you get an image that builds cleanly, starts, prints its banner, and then exits with
ERR_MODULE_NOT_FOUND from dist/auth/auth-hooks.js — which a build-only check calls success.
The test before removing anything: does this package ship components, or JS the server imports?
Components are bundled by Vite and safe to delete — @lucide/svelte goes for exactly this reason,
since Node cannot import a .svelte file and the copy in node_modules is unreachable by
construction. JS that cms-core imports at runtime is live.
Two things worth knowing:
ADAPTER=nodeselects@sveltejs/adapter-nodeso the build emitsbuild/index.js. The template'ssvelte.config.jsdefaults toadapter-autootherwise. If your platform runspnpm builditself rather than building this Dockerfile, either set that variable in the platform's build config or makeadapterNode()unconditional insvelte.config.js.- The build needs no environment. Every server module guards its initialisation
with
buildingfrom$app/environment, so SvelteKit's analyse pass doesn't need a database or an auth secret. Real values are required at runtime only. (This was not always true — older template versions crashed the build without a.env.)
The entrypoint
docker-entrypoint.sh exists for one reason: the app has to know its own public
URL, and on a managed platform nobody knows it until provisioning finishes.
adapter-node builds event.url from its own internal host:port unless ORIGIN says
otherwise. Better Auth compares that origin against AUTH_URL and, on a mismatch,
declines the request — and the decline lands on the API catch-all as a bare 404. The
result is a deploy where the site renders, the admin loads, and sign-up returns a 404
with nothing in the log explaining why. It is the single most common way a first deploy
fails.
So on boot the entrypoint:
- Derives
AUTH_URLfromRENDER_EXTERNAL_URL,RAILWAY_PUBLIC_DOMAIN,APP_URL,COOLIFY_URLorFLY_APP_NAME, if you haven't set one. An explicitAUTH_URLalways wins, and you should set one as soon as a custom domain exists — outgoing email links are built from it. - Sets
ORIGINto match, which is whatadapter-nodeactually reads. - Refuses to start without
AUTH_SECRET, rather than coming up publicly reachable with unsigned sessions. - Applies migrations, on Postgres only, then
execs the server.
That last point is deliberate and easy to get wrong if you write your own entrypoint:
aphex migrate is a Postgres-only step. The drizzle/ folder holds the PostgreSQL migration
history; the SQLite adapter has no migration folder and provisions its schema at startup with a
push instead. Running aphex migrate on the SQLite path doesn't no-op — the command reads
DATABASE_URL/APHEX_DATABASE and cannot see APHEX_SQLITE_URL, so it exits with "No database
configured" and crash-loops the container before the app is ever reached.
exec node build matters too: without it the shell stays PID 1, swallows SIGTERM,
and every deploy waits out the platform's kill timeout before the container dies.
Escape hatches
| Variable | Effect |
|---|---|
AUTH_URL / ORIGIN | Set either explicitly and the derivation is skipped. |
APHEX_SKIP_MIGRATE | true skips the migration step — for when you migrate as a pre-deploy step. |
APHEX_DB_AUTO_MIGRATE | false also stops the app migrating on boot. Use both with several replicas. |
Running as a non-root user
The server runs as node (uid 1000), not root. Getting there needs slightly more than a
USER line, because the two requirements pull against each other:
- Kubernetes'
restrictedPod Security Standard requiresrunAsNonRoot— a root image is rejected outright. - Every platform in these guides mounts its volume owned by root, so a non-root
process fails on first boot with
EACCES: permission denied, mkdir '/data/uploads'. That's the failure Railway papers over withRAILWAY_RUN_UID=0, which hands root back and loses the point.
So the entrypoint starts as root, chowns only the paths it is about to write, then hands
the server to node via su-exec. The chown is not recursive except on a directory it
just created — a recursive pass over a media library is a slow, pointless cost on every
restart.
If the container is started as non-root (runAsNonRoot, or docker run --user), the
entrypoint detects it, skips the chown and execs directly. Volume ownership is then the
orchestrator's job via fsGroup or an init container, which is the right division of
responsibility.
The image is root-capable, not rootless — docker exec still lands you as root, and a policy
that demands runAsNonRoot still needs that flag set. What changed is that with the flag set, it
works.
What has to be on a volume
Two paths, and they default to inside the image:
APHEX_SQLITE_URL=file:/data/aphex.db # the database
APHEX_UPLOADS_DIR=/data/uploads # local media storageAssets are stored with /media/:id/:filename URLs and resolved through the storage
adapter, so moving APHEX_UPLOADS_DIR rewrites nothing in the database — but it
doesn't move existing files either. Set it before the first upload, or copy the old
directory across yourself while preserving its contents' relative layout. The template
rebases persisted paths from its former ./static/uploads and ./uploads defaults to the
current root. Remove the old static/uploads directory after copying so those files are not
publicly served outside the access-controlled /media route.
If you're using S3-compatible storage (S3_*), only the database needs the volume, and
on Postgres you need no volume at all.
Background jobs
APHEX_EMBEDDED_WORKER=true appears in the docker run line and in
docker-compose.prod.yml above, and it is not decoration. It runs the job queue
in-process — scheduled publishes, event consumers, anything a plugin enqueues.
Without it the queue fills and never drains. A scheduled publish is accepted, reports success, and then simply never happens. There is no error to find; the symptom is a post that didn't go live, noticed later.
APHEX_EMBEDDED_WORKER=true # one container — the usual answerOne container only. Each replica runs its own loop, so two containers with this set both claim
and process jobs. Leases and idempotency keys mean the damage is bounded rather than silent
duplication, but it is still contention you don't want. Past one replica, leave it off and drive
POST /api/internal/workers/run from outside — a platform cron, or the bundled
scripts/worker.ts poll loop — with APHEX_WORKER_SECRET set. That endpoint 404s while the
secret is unset, so it is never an unauthenticated surface.
Setup for both shapes, and how to verify the queue is actually draining: Operations → Background jobs.
Postgres instead
One variable and a connection string; no code change:
APHEX_DATABASE=postgres
DATABASE_URL=postgres://user:pass@host:5432/aphex?sslmode=requireUncomment the db service in docker-compose.prod.yml to run it alongside, or point
at a managed instance. The default pool is 10 — fine for a VPS, drop it for serverless.
With more than one container against the same database, migrate as a deploy step rather than on boot:
docker compose -f docker-compose.prod.yml run --rm app \
node node_modules/@aphexcms/cms-core/dist/cli/index.js migratethen set APHEX_SKIP_MIGRATE=true and APHEX_DB_AUTO_MIGRATE=false on the running
containers.
Updating
git pull
docker compose -f docker-compose.prod.yml up -d --build appOnly the app rebuilds; the volume and any database service persist. Read
CHANGELOG.md in the template repo before upgrading — the template is meant to be
customized, so upstream changes aren't applied to your copy automatically, and the
changelog is the list of what you may want to port.
Fly.io
Docker-friendly, so the bundled image drops straight in:
fly launch --dockerfile Dockerfile --no-deploy
fly volumes create aphex_data --size 5Then in fly.toml:
[http_service]
internal_port = 3000
auto_stop_machines = false # a volume pins this to one machine anyway
[[mounts]]
source = "aphex_data"
destination = "/data"
[env]
APHEX_SQLITE_URL = "file:/data/aphex.db"
APHEX_UPLOADS_DIR = "/data/uploads"
APHEX_EMBEDDED_WORKER = "true"
[checks.health]
type = "http"
path = "/healthz"fly secrets set AUTH_SECRET="$(openssl rand -base64 48)"
fly deployThe entrypoint derives AUTH_URL from FLY_APP_NAME, so the .fly.dev hostname works
without configuration. Set AUTH_URL explicitly once you attach your own domain.
Leave auto_stop_machines off. Fly's scale-to-zero suspends the machine, and a suspended machine
runs no queue loop — scheduled publishes would fire late or not at all, and the first request
after a stop pays a cold start.
Last updated on