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

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 a pgdata volume too, so compose.deploy.yaml sets its own project name and names its volume outright. Without that, compose puts both stacks on one volume — and because Postgres ignores POSTGRES_* once a data directory exists, the deployment would silently run against the development database while its migration failed with role "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 migrate says why. It runs packages/db/src/migrate.ts on drizzle-orm's migrator, not drizzle-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 bare Exit 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 exists

The 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:migrate locally, so the two paths cannot drift.

Do not point db:migrate at a database that was created with db:push. The development flow pushes the schema directly and records no migration history, so migrate would try to create tables that already exist and fail. The two flows are alternatives, not steps; the deployment database is created by migrate from 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.