---
title: VPS
description: Running the production stack on one machine you own, with TLS, migrations, rollouts and backups.
type: guide
related:
- /en/docs/deployment/docker
---
# VPS
`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:
```sh
cp .env.docker.example .env.docker
cp .env.docker.prod.example .env.docker.prod
```
Both 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
```sh
bun run docker:prod
```
which is:
```sh
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
```sh
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
```sh
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.
```sh
sudo mkdir -p /etc/kreogen
sudo install -m 0600 /dev/null /etc/kreogen/cron.env
# CRON_SECRET=
# 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:
```sh
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:
```ini
# /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
```
```ini
# /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.
---
For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)
For an index of all available documentation, see [/llms.txt](/llms.txt)