Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard
Deployment

VPS

Running the production stack on one machine you own, with TLS, migrations, rollouts and backups.

docker-compose.prod.yml is the whole production stack: Traefik terminating TLS, Postgres, Redis, a migration job that must succeed before any app starts, and the three apps behind health-gated routing. It is designed for one machine you own — a $10 VPS is enough to run all of it — and it is what makes the "self-hostable" claim in the README true rather than aspirational.

This page is the install. It assumes a fresh host with Docker Engine and the compose plugin, a domain whose A record points at it, and ports 80 and 443 reachable.

Two environment files, and why

The stack reads two files, and the split is not cosmetic. Compose has two different mechanisms and they do not see the same variables.

FileRead byHolds
.env.dockerenv_file:, so the app containers' own environmentApplication variables — DATABASE_URL, BETTER_AUTH_SECRET, every integration key
.env.docker.prodcompose itself, to interpolate ${...} in the compose fileHost and stack configuration — the domain, the TLS contact, the Postgres superuser

env_file: puts a variable inside a container. It does not make it available to ${...} interpolation in the compose file, which compose resolves before it starts anything, reading only .env in the project directory or the file named by --env-file. That is why every command below carries --env-file .env.docker.prod, and why leaving it off fails immediately with an interpolation error on POSTGRES_PASSWORD rather than with something that looks like a deployment problem.

The split also keeps a Postgres superuser password out of three application containers that have no use for it, and keeps the audit honest: .env.docker is read by kreogen audit, and every key in it is one some package's keys() declares. DOMAIN and ACME_EMAIL are not application variables and never will be, so documenting them there would report them as documented-but-declared- nowhere.

bun run verify runs docker/check-compose-env.mjs, which asserts that every ${...} in docker-compose.prod.yml is documented in .env.docker.prod.example and that nothing documented there has stopped being read. That gate ships to generated projects, because the compose file does.

Fill them in

kreogen init writes both from their examples, so a generated project already has them. Working from a clone of the template itself:

cp .env.docker.example .env.docker
cp .env.docker.prod.example .env.docker.prod

Both are gitignored. Filling in .env.docker.prod:

VariableUnset means
DOMAINTraefik's router rules become Host() with nothing in them, and no request reaches any app
ACME_EMAILLet's Encrypt refuses to issue without a contact, so TLS never comes up and every request is a redirect loop
POSTGRES_USERCompose refuses to start. There is deliberately no default
POSTGRES_PASSWORDCompose refuses to start
POSTGRES_DBCompose refuses to start

The three POSTGRES_* values have to match the credentials and database name in DATABASE_URL in .env.docker. Compose creates the database from the former and the apps connect with the latter; a disagreement is not a login failure on the first request but migrate exiting with "database does not exist", with all three apps blocked behind it and nothing saying why.

The role and the database used to default to kreogen. A project generated from the template therefore created a database named after the template while its own DATABASE_URL named the project, and the first migration failed. They are required now for that reason — a default that is right for exactly one repository is worse than no default.

Then set the routing. DOMAIN=example.com serves the marketing site on example.com and www.example.com, the product on app.example.com and the API on api.example.com. All three subdomains need A records.

Bring it up

bun run docker:prod

which is:

docker compose --env-file .env.docker.prod -f docker-compose.prod.yml up -d

Order is enforced by the compose file rather than by you remembering it. Postgres has to pass its healthcheck before migrate runs, migrate has to exit zero before any app starts, and each app is routed to only once it answers /ready. Starting an app against an unmigrated schema produces a container that boots cleanly and then errors on every request, which is the failure this ordering exists to make impossible.

Traefik requests certificates on first request per host, so the first load of each subdomain is slow. docker compose -f docker-compose.prod.yml logs -f traefik shows the ACME exchange if it does not complete.

Deploying a new version

bun run deploy                     # roll out :main
TAG=v1.4.0 bun run deploy          # roll out a specific tag
bun run deploy -- --rollback       # return to the previous tag

docker compose up -d on its own stops the old container before the new one is ready, so every deploy drops requests for as long as Next takes to boot. docker/deploy.sh does not: it applies migrations once, separately, then for each app scales to two replicas, waits for the new one to answer /ready, and only then drops back to one — which removes the old container. If the new replica never becomes ready within READY_TIMEOUT seconds, the script scales back to the old one and exits non-zero without having taken the site down.

It records the tag it rolled out under .deploy/, which is what --rollback reads. A rollback is therefore only available after a deploy that succeeded through this script.

Migrations are not rolled back. That is deliberate and it is the constraint to design around: write migrations that an older application version can still run against, or accept a maintenance window.

Backups

bun run backup                          # write a dump
bun run backup -- --restore FILE        # restore one
bun run backup -- --verify FILE         # restore into a scratch database

A dump goes to BACKUP_DIR (default ./backups) in Postgres's custom format, gzipped, and anything older than RETENTION_DAYS (default 14) is deleted. Custom format rather than plain SQL because pg_restore can then restore selectively, which is what you want when the thing you need back is one table.

Set BACKUP_S3_BUCKET and the dump is also copied off the host with aws s3 cp. Leave it unset and the script says so on every run, because a dump on the same disk as the database it came from survives a bad migration and nothing else.

--verify creates a scratch database on the live Postgres instance, restores the dump into it, counts the tables and drops it again. The drop is installed as an exit trap, so it happens on failure too. It is a real restore against real infrastructure, not a smoke test — which is what makes it worth running, and why you should know what it does before you schedule it.

A dump nobody has restored is not a backup. Run --verify on a schedule; the time to discover a dump is unusable is not during an incident.

Scheduling

The repository ships systemd units for the scheduled-work endpoint and deliberately ships none for backups.

sudo mkdir -p /etc/kreogen
sudo install -m 0600 /dev/null /etc/kreogen/cron.env
# CRON_SECRET=<the same value as in .env.docker>
# CRON_URL=https://api.example.com
sudo cp docker/systemd/kreogen-cron.* /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now kreogen-cron.timer

The secret lives in an EnvironmentFile rather than in the unit so it is not world-readable in systemctl cat. CRON_URL is there too, because nothing in the compose stack reads it — only the unit does. A mismatch between it and CRON_SECRET in .env.docker answers 401 and the sweep silently never runs, so check the timer after enabling it:

systemctl list-timers kreogen-cron.timer
journalctl -u kreogen-cron.service -n 20

Why there is no backup timer

A schedule, a retention window, and whether an automated restore may touch the production instance are policy, and this repository cannot choose them for you. Shipping a unit that runs --verify weekly would be shipping a decision to create and drop a database on somebody's live Postgres on a schedule they never agreed to.

Wiring it up yourself is ten minutes, and the shape is the pair already installed above:

# /etc/systemd/system/kreogen-backup.service
[Unit]
Description=kreogen postgres backup
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
WorkingDirectory=/opt/kreogen
EnvironmentFile=/etc/kreogen/backup.env
ExecStart=/opt/kreogen/docker/backup.sh
# /etc/systemd/system/kreogen-backup.timer
[Unit]
Description=Run the kreogen postgres backup daily

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=15m

[Install]
WantedBy=timers.target

WorkingDirectory has to be wherever the compose file actually lives; the script asserts it can see docker-compose.prod.yml and exits otherwise. backup.env carries BACKUP_S3_BUCKET, BACKUP_DIR and RETENTION_DAYS. Offset the schedule from kreogen-cron.timer's 04:00 so a dump does not run mid-sweep.

For verification, a second pair calling /opt/kreogen/docker/backup.sh --verify against the newest file in BACKUP_DIR, weekly — after reading the warning above about what --verify does to the live instance.

What a generated project keeps

docker/, docker-compose.prod.yml and both example env files ship to every project generated by the CLI. This page does not: apps/docs is stripped. The per-variable comments in .env.docker.prod.example and the comment block at the top of docker-compose.prod.yml are the copy of this information a client project has, which is why they are written to stand on their own.