Deploying chronq
compose.deploy.yaml runs chronq as three containers: Postgres, a one-shot
migration, and the app. The app image is a standalone node build with no
node_modules and no package manager, built by Dockerfile at the repo root.
- Quick start
- What the stack runs
- Body size limit
- ORIGIN, and the 403 you get without it
- Migrations
- Environment
- Passwords and the connection URL
- The application secret
- Behind a proxy
- Operating it
- Troubleshooting
Quick start
cp .env.deploy.example .env.deploy
# edit it: POSTGRES_PASSWORD (openssl rand -hex 32), SECRET (openssl rand -base64
# 48), and ORIGIN unless you really are serving http://localhost:8080. Compose
# refuses to start without a POSTGRES_PASSWORD, a SECRET, or an ORIGIN.
docker compose --env-file .env.deploy -f compose.deploy.yaml up -d --build
Then open http://localhost:8080. A fresh database has no admin, so the first
request redirects to /setup.
.env.deploy is separate from the development .env and is only read when you
pass it with --env-file, so the two never mix. It is gitignored.
What the stack runs
| Service | Image | What it does |
|---|---|---|
db |
postgres:18-alpine |
Data in the named volume chronq-deploy-pgdata. No port is published. |
migrate |
Dockerfile target build |
Applies packages/db/drizzle, then exits. |
app |
Dockerfile target runtime |
The server, and the delivery worker inside it. Published on ${APP_PORT:-127.0.0.1:8080}:3000 — loopback, so it is not on the VPS's public IP. |
The app waits for db to be healthy and for migrate to exit 0, so a schema
change can never race a boot.
All three services share one network, chronq_network. It is named outright —
compose would otherwise prefix it with the project name — and naming it means
compose does not also create an implicit <project>_default beside it.
It is deliberately not internal: true: the delivery worker makes outbound
HTTP requests to whatever destination a message names, so the app needs egress.
What keeps the database off the host is publishing no port for it, not the
network. A reverse proxy in another project can join it with
docker network connect chronq_network <container>.
Both compose files live in this directory.
compose.yaml(development) declares apgdatavolume too, socompose.deploy.yamlsets its own project name and names its volume outright. Without that, compose puts both stacks on one volume — and because Postgres ignoresPOSTGRES_*once a data directory exists, the deployment would silently run against the development database while its migration failed withrole "chronq" does not exist.
Body size limit
adapter-node caps request bodies at 512 kb by default. The publish API accepts 1 MiB, so out of the box the adapter refuses large publishes before the app can answer:
$ curl -X POST .../v2/publish/... --data-binary @600kb.json
{"message":"Payload Too Large"} HTTP 413
The image sets BODY_SIZE_LIMIT=2M, which lets the app produce its own errors —
body must be 1048576 bytes or fewer for anything over its limit. Keep it above
1 MiB, or the publish API quietly loses half its range.
ORIGIN, and the 403 you get without it
SvelteKit decides which origin a form post is allowed to come from, and in
production it enforces that — vite dev does not:
| Request | Development | Deployment |
|---|---|---|
POST /login with a form content type, no Origin |
accepted | 403 Cross-site POST form submissions are forbidden |
Same, Origin matching ORIGIN |
accepted | accepted |
So a wrong ORIGIN does not fail loudly at boot — it fails when someone tries to
sign in. Set it to the URL in the address bar, including the scheme and port. If
you terminate TLS elsewhere, it is the public https:// URL.
Migrations
packages/db/drizzle holds the SQL, generated from the Drizzle schema:
pnpm --filter @chronq/db db:generate # after changing packages/db/src/schema
The migrate service applies whatever is in that folder on every up. It is
idempotent: Drizzle records applied migrations in drizzle.__drizzle_migrations
and skips them next time. To ship a schema change, generate the migration, commit
it, and redeploy.
A failed
migratesays why. It runspackages/db/src/migrate.tson drizzle-orm's migrator, notdrizzle-kit migrate, which reports through a spinner that erases the line it wrote — an auth failure, a refused connection and a broken migration all came out as pnpm's bareExit status 1, and the Postgres container's log only explains the first of the three. What you get now is a line like:[migrate] FAILED — 28P01: password authentication failed for user "chronq" [migrate] FAILED — ERR_INVALID_URL: Invalid URL [migrate] FAILED — ENOTFOUND: getaddrinfo ENOTFOUND chronq [migrate] FAILED — 42P07: relation "queue" already existsThe code is the Postgres SQLSTATE on a SQL failure and the Node code on a connection failure. The same script is
pnpm --filter @chronq/db db:migratelocally, so the two paths cannot drift.
Do not point
db:migrateat a database that was created withdb:push. The development flow pushes the schema directly and records no migration history, somigratewould try to create tables that already exist and fail. The two flows are alternatives, not steps; the deployment database is created bymigratefrom empty, which is the case it is built for.
The migrate service runs from the build stage because drizzle-kit is a
devDependency of packages/db. That image is ~835 MB — it is the whole
workspace with its dependencies — but it is a one-shot, and the app image stays
small.
Environment
Set these in .env.deploy; the compose file passes them to the containers.
| Variable | Default | Notes |
|---|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
chronq / required / chronq |
What the db service creates the cluster with, and what the compose file builds DATABASE_URL from. Compose refuses to start without a password. Generate it with openssl rand -hex 32 — see below. |
SECRET |
required | The application secret. Compose refuses to start without it, and so does the app. openssl rand -base64 48. See The application secret. |
APP_PORT |
127.0.0.1:8080 |
Host publish spec; the container always listens on 3000. Loopback by default, so the app is not reachable as <vps-ip>:8080. APP_PORT=8080 puts it on every interface. |
ORIGIN |
required | The URL you reach the app on. No default: see above. |
BODY_SIZE_LIMIT |
2M |
Keep above 1 MiB. |
ADDRESS_HEADER |
unset | Behind a proxy, the header holding the client's address (X-Real-IP), so sign-in throttling is per client. See below. |
The image itself sets NODE_ENV=production, HOST=0.0.0.0, PORT=3000.
Passwords and the connection URL
The compose file builds the connection string from the three variables, so the password is written once:
DATABASE_URL: postgresql://${POSTGRES_USER:-chronq}:${POSTGRES_PASSWORD:?...}@db:5432/${POSTGRES_DB:-chronq}
Compose interpolates that as text and cannot percent-encode anything, so a / ? #
in POSTGRES_PASSWORD ends the URL early: the containers never dial the database.
/ and ? and # are the only three that do this — a % or an @ leaves a URL
that parses and then fails to authenticate, which is a different message — and
the migration names the character it stopped at, without printing the password:
[migrate] target postgresql://chronq:***@db:5432/chronq
[migrate] DATABASE_URL does not parse as a URL, so nothing was dialled.
[migrate] its credentials end after 2 characters, at a "/" — a password containing / ? or # must be percent-encoded in the URL.
[migrate] FAILED — ERR_INVALID_URL: Invalid URL
Generate a password that cannot contain them and the case does not arise:
openssl rand -hex 32
Any other password has to be percent-encoded inside the URL (%2F %3F %23 %25),
which means the URL can no longer be built from POSTGRES_PASSWORD — write
DATABASE_URL out by hand in that case, and point it wherever the database is.
To use a database outside this stack, change that DATABASE_URL line in both
services (migrate and app) to name it, and drop the db service. There is no
override variable on top of it: the compose file is the only place the URL exists,
which is also why there is nothing there to get out of step with
POSTGRES_PASSWORD.
Changing POSTGRES_PASSWORD — or POSTGRES_USER — on a stack that has already
run does not change the cluster: Postgres ignores POSTGRES_* once a data
directory exists. The migration says so rather than leaving you with the SQLSTATE:
[migrate] target postgresql://postgres:***@db:5432/chronq
[migrate] FAILED — 28P01: password authentication failed for user "postgres"
[migrate] the database was created with different credentials than these.
[migrate] Postgres ignores POSTGRES_* once its volume exists, so an edited
[migrate] .env.deploy needs `down -v` (which deletes the data), or ALTER USER.
Note the user in that message: it is whoever DATABASE_URL asked for, which on a
stack whose volume predates the edit is not necessarily the role that exists.
The application secret
SECRET is the only secret the app takes, and it is required: 32 characters
minimum, no default, no fallback. Everything else it needs is generated per
instance and kept in Postgres, which is deliberate — a copy of the database is
not supposed to be enough on its own. SECRET is the part that is missing from
that copy:
- Session ids are stored as an HMAC of the cookie token keyed with
SECRET, so a leaked row cannot be turned back into a working session. - The publish token is stored sealed (AES-256-GCM). It is not hashed because the account page has to display it again.
The signing keys are not wrapped in it. They are random per instance, and both
the account page and the SDK examples read them straight out of signing_key, so
sealing that column would break the documented receiver setup for no gain.
Generate it once, keep it out of the repository, and back it up:
openssl rand -base64 48
Changing it is not free. Every session is invalidated (the stored ids no longer
match), and a sealed publish token will not open — open() throws rather than
silently hand out a token that cannot match, so the publish API returns 500 until
the token is rotated on /app/account. Rotate the token in the UI before
changing SECRET if you want no gap.
An instance upgraded from a release without SECRET will not start until the
variable is set: SECRET is not set. Once it is, existing sessions are logged out
by definition (their ids are plain SHA-256 digests from the old scheme), and a
publish token written before the upgrade is read as-is until the next rotation.
Behind a proxy
nginx terminates TLS and proxies to the app. Three things have to line up: where the app is published, what URL it believes it is on, and the body size limit.
It is published on loopback already
The default publish spec is 127.0.0.1:8080, so the proxy is the only way in and
there is nothing to turn off: <vps-ip>:8080 does not answer, and neither does
any other interface.
# .env.deploy — only ORIGIN has to be set for a proxy
ORIGIN=https://queue.example.com
APP_PORT is interpolated straight into the compose port mapping, so widening it
is one line — APP_PORT=8080 publishes on every interface, which is a deliberate
choice to firewall, not the default.
nginx
server {
listen 80;
server_name queue.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name queue.example.com;
ssl_certificate /etc/letsencrypt/live/queue.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/queue.example.com/privkey.pem;
# Must be at least BODY_SIZE_LIMIT, which is 2M here. nginx defaults to 1m,
# which is *below* the publish API's own 1 MiB limit, and it answers a large
# publish with its own HTML 413 before chronq can produce a useful error.
client_max_body_size 2m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo ln -s /etc/nginx/sites-available/chronq /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d queue.example.com issues the certificate and writes the
TLS block for you if you would rather not maintain it by hand.
Nothing else is needed: no WebSocket upgrade, no raised proxy_read_timeout.
Every response is either a rendered page or a small JSON body.
What URL the app thinks it is on
Set ORIGIN to the public URL, scheme included. compose.deploy.yaml always
passes one, defaulting to http://localhost:8080, which is wrong as soon as a
proxy is in front — and the failure is a 403 Cross-site POST form submissions are forbidden at sign-in, not anything at boot.
The adapter can derive the origin instead: with ORIGIN unset it builds
https://<Host header>, so an HTTPS proxy that forwards Host works either way.
Setting ORIGIN regardless is worth it, because it pins the trusted origin — with
it set, a forged Host no longer moves the value the CSRF check compares against.
PROTOCOL_HEADER, HOST_HEADER and XFF_DEPTH do nothing in this stack:
compose.deploy.yaml never passes them, and ORIGIN is always set so they are
never consulted.
The client address, and sign-in throttling
Sign-in locks a client out for 15 minutes after 10 failed attempts, keyed by
getClientAddress(). Behind a proxy every request arrives from the proxy, so
without help that address is the same for everyone and one person guessing
passwords locks the admin out too. Tell the adapter which header carries the
real address — the nginx config above sets X-Real-IP:
# .env.deploy
ADDRESS_HEADER=X-Real-IP
Set it only when a proxy is in front and overwrites that header. Reached directly, a client can write the header itself and pick any address it likes, and with it set, a request that lacks the header fails outright. Left unset (the default), the socket address is used, which is right when nothing is in front.
Operating it
# logs
docker compose --env-file .env.deploy -f compose.deploy.yaml logs -f app
# apply a new version
git pull && docker compose --env-file .env.deploy -f compose.deploy.yaml up -d --build
# stop, keeping data
docker compose --env-file .env.deploy -f compose.deploy.yaml down
# back up
docker compose --env-file .env.deploy -f compose.deploy.yaml exec -T db \
pg_dump -U chronq chronq | gzip > chronq-$(date +%F).sql.gz
Logs. app and db cap their json-file logs at 10 MB × 3, so a long-running
instance cannot fill the disk. Set that globally in /etc/docker/daemon.json
instead if you would rather, and drop the logging: blocks from the compose file.
Health. The image defines a HEALTHCHECK that requests /login and accepts
200 or 307 — a fresh instance redirects to /setup, which is still "up".
docker compose ps reports it.
Delivery capacity. Every app replica runs a delivery worker: hooks.server.ts
starts pg-boss and registers one per queue at boot. pg-boss gives each job to a
single worker, so several replicas share the queues safely. To add delivery
capacity without adding web capacity, run extra workers from the build stage —
they add throughput, but the app keeps its own worker:
worker:
build:
context: .
target: build
command: pnpm --filter @chronq/jobs worker
environment:
DATABASE_URL: *database-url
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
Troubleshooting
| Symptom | Cause |
|---|---|
role "chronq" does not exist from migrate |
The stack is attached to a volume initialised with different credentials — usually the development one. Check the project name and volume name. |
Invalid URL from the app or migrate |
POSTGRES_PASSWORD contains /, ?, # or % and ends the URL early. Generate a hex password, or write DATABASE_URL by hand with the password percent-encoded. See above. |
| Compose refuses to start, "required variable ORIGIN is missing a value" | ORIGIN has no default on purpose — set it in .env.deploy. See above. |
Cross-site POST form submissions are forbidden |
ORIGIN does not match the URL in the browser. See above. |
{"message":"Payload Too Large"} on publish |
BODY_SIZE_LIMIT is at the adapter's 512 kb default, or below the body you sent. |
Error: DATABASE_URL is not set during build |
The placeholder in the Dockerfile was removed from the build step. |
Error: SECRET is not set (or "must be at least 32 characters") |
Set SECRET in .env.deploy. It has no default and the app refuses to start without it. See above. |
| Compose refuses to start, "required variable SECRET is missing a value" | Same thing, caught by compose before the container starts. |
| Everyone is logged out after a deploy | SECRET changed. Session ids are keyed with it. See above. |
500 on /v2/publish and "a sealed value in the database did not open" in the logs |
SECRET changed, so the stored publish token cannot be unsealed. Rotate the token on /app/account. |
Build fails with SECRET is not set |
The SECRET placeholder was removed from the build stage in the Dockerfile; SvelteKit's postbuild analysis imports the server, which reads it at module load. |
| App container restarts in a loop | Read its logs; a missing database is the usual cause, and migrate exiting non-zero is the usual reason it is missing. |
[migrate] FAILED — 28P01: password authentication failed |
The pgdata volume was created by an earlier run with a different password or user than .env.deploy now says. Migrate prints what to do about it; see above. |
[migrate] FAILED — ERR_INVALID_URL or — ENOTFOUND: getaddrinfo |
The built URL is malformed, so nothing was dialled: / ? # in POSTGRES_PASSWORD ended it early — the lines above the failure say which character and how far in. A hex password cannot hit this. |
[migrate] FAILED — 42P07: relation … already exists |
The database has a schema but no migration history, which is what db:push leaves behind. Drop the volume, or generate the migration that matches it. |
| Publishes accepted, never delivered | The worker runs in the app container — check [chronq] pg-boss started, delivery workers registered in its logs. |
413 with the app's own message |
Working as intended: the body is over the publish API's 1 MiB limit. |