--- 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)