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.
| File | Read by | Holds |
|---|---|---|
.env.docker | env_file:, so the app containers' own environment | Application variables — DATABASE_URL, BETTER_AUTH_SECRET, every integration key |
.env.docker.prod | compose itself, to interpolate ${...} in the compose file | Host 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.prodBoth are gitignored. Filling in .env.docker.prod:
| Variable | Unset means |
|---|---|
DOMAIN | Traefik's router rules become Host() with nothing in them, and no request reaches any app |
ACME_EMAIL | Let's Encrypt refuses to issue without a contact, so TLS never comes up and every request is a redirect loop |
POSTGRES_USER | Compose refuses to start. There is deliberately no default |
POSTGRES_PASSWORD | Compose refuses to start |
POSTGRES_DB | Compose 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:prodwhich is:
docker compose --env-file .env.docker.prod -f docker-compose.prod.yml up -dOrder 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 tagdocker 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 databaseA 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.timerThe 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 20Why 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.targetWorkingDirectory 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.