Deploy: Solo (single VM)
This is the smallest production topology for a Kumiko server app: one VM runs the app, Postgres, and Redis. It suits an early-stage, single-tenant, or internal-tool app.
Trade-offs
Section titled “Trade-offs”- One server runs the app, database, and Redis.
- A nightly database dump must go to separate storage.
- An update uses
docker compose pullfollowed bydocker compose up -d. - There is no horizontal scaling or Postgres high availability.
- The VM is a single point of failure.
The cost example in older versions of this page was provider- and date-specific, so it is intentionally omitted. Choose a VM size from your current workload and provider pricing.
Before you start
Section titled “Before you start”- You have a VM with Docker and Compose v2 installed.
- You have a registry containing the app image.
- DNS points the app hostname at the VM or at a reverse proxy on the VM.
- You have an off-site backup target and credentials for it.
- You have read Deploy: Docker and have a previous image tag available for rollback.
- You have set
DB_PASSWORDin the Compose environment. Use a URL-safe password or URL-encode it inDATABASE_URL.
1. Build a production image
Section titled “1. Build a production image”In the app repository:
bun run buildThis produces dist/ and dist-server/, including the bundled kumiko.js
CLI. Use the Docker deployment page for the
runtime image and the current deploy template.
2. Create docker-compose.yml
Section titled “2. Create docker-compose.yml”The example keeps the application secrets as placeholders. Replace them with secret injection suitable for your VM. Do not commit real passwords.
services: app: image: ghcr.io/your-org/your-app:sha-<short-commit> environment: DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app REDIS_URL: redis://redis:6379 depends_on: - db - redis ports: - "127.0.0.1:3000:3000" restart: unless-stopped
db: image: postgres:18-alpine environment: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pg-data:/var/lib/postgresql/data restart: unless-stopped
redis: image: redis:7-alpine restart: unless-stopped
volumes: pg-data:The app must also receive every other variable required by its environment schema, including authentication and encryption settings where applicable.
3. Apply migrations
Section titled “3. Apply migrations”Run the migration command for the image you are about to start:
docker compose run --rm app bun /app/kumiko.js schema applyThis applies pending SQL migrations and retries queued projection rebuilds. It is safe to run on every release. Stop if it returns an error. Do not start the new app image against a database whose migration or projection rebuild failed.
4. Start the app
Section titled “4. Start the app”docker compose up -ddocker compose psCheck the app health endpoint and logs:
curl --fail http://127.0.0.1:3000/healthdocker compose logs --tail=100 appIf the app exits with SchemaDriftError, run the migration step again and
inspect its output before retrying the app.
Put a reverse proxy such as Caddy, Traefik, or nginx in front of the app and terminate TLS there. A Cloudflare Tunnel is another option when you do not want to expose an inbound application port.
Backup
Section titled “Backup”A database dump is only useful when it reaches storage outside the VM. The following cron shape is an example; adapt the bucket, credentials, and backup retention policy to your provider:
0 3 * * * docker compose exec -T db pg_dump -U app app | gzip | aws s3 cp - s3://YOUR_BACKUP_BUCKET/$(date +\%Y-\%m-\%d).sql.gzRun a restore drill regularly. Keep the previous image tag and database backup available before a release so you have a recovery path.
Rollback
Section titled “Rollback”- Stop approving traffic to the new app if the health or smoke check fails.
- Restore the previous image tag in
docker-compose.yml. - Run
docker compose pullanddocker compose up -d app. - Check
/healthand the application smoke path.
A database migration is not automatically reversible. If the release changed the schema in a way the previous image cannot read, restore the database from a known-good backup or follow a separately tested rollback migration.