Skip to content

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.

  • One server runs the app, database, and Redis.
  • A nightly database dump must go to separate storage.
  • An update uses docker compose pull followed by docker 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.

  • 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_PASSWORD in the Compose environment. Use a URL-safe password or URL-encode it in DATABASE_URL.

In the app repository:

Terminal window
bun run build

This 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.

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.

Run the migration command for the image you are about to start:

Terminal window
docker compose run --rm app bun /app/kumiko.js schema apply

This 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.

Terminal window
docker compose up -d
docker compose ps

Check the app health endpoint and logs:

Terminal window
curl --fail http://127.0.0.1:3000/health
docker compose logs --tail=100 app

If 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.

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:

Terminal window
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.gz

Run a restore drill regularly. Keep the previous image tag and database backup available before a release so you have a recovery path.

  1. Stop approving traffic to the new app if the health or smoke check fails.
  2. Restore the previous image tag in docker-compose.yml.
  3. Run docker compose pull and docker compose up -d app.
  4. Check /health and 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.