Docker environment for Strav — the Bun backend framework. Covers local development with hot-reload and a hardened multi-replica production setup with Caddy, PostgreSQL, and Redis.
- Architecture
- Requirements
- Development setup
- Production setup
- Service profiles
- Environment variables
- Common tasks
- Image internals
┌──────────┐
Browser ────► │ Caddy │ :80 / :443 (HTTP/3)
└────┬─────┘
│ reverse proxy
┌──────┴──────┐
│ web ×2 │ :3000 (Bun / Strav)
└──────┬──────┘
┌──────┴──────┐
│ PostgreSQL │ ← persistent volume
└─────────────┘
┌─────────────┐
│ Redis │ ← queue, sessions, cache
└─────────────┘
┌─────────────┐
│ worker │ (optional profile)
└─────────────┘
┌─────────────┐
│ scheduler │ (optional profile, 1 replica only)
└─────────────┘
Caddy handles TLS (self-signed in dev via its local CA, Let's Encrypt in production), compression, security headers, and static file serving. Only Caddy is exposed to the internet; all app services communicate over the internal Docker network.
| Tool | Version |
|---|---|
| Docker Engine | 24+ |
Docker Compose plugin (docker compose) |
v2.20+ |
| Bun | 1.0+ |
Note: This setup uses the Compose v2 plugin (
docker compose), not the legacydocker-composev1 binary.
This repo is a Docker template — it needs a Strav application alongside it to build and run. Scaffold the app first, then copy the Docker files in:
# 1. Create a new Strav app
bunx @strav/spring my-app --web --db=my_app_db
cd my-app
# 2. Copy the Docker files from this template into the app directory
curl -fsSL https://github.com/go4cas/strav-docker/archive/refs/heads/main.tar.gz \
| tar -xz --strip-components=1 strav-docker-mainAlternatively, if you have already cloned this repo:
cp /path/to/strav-docker/{Dockerfile,docker-compose.yml,docker-compose.prod.yml,\
Caddyfile,docker-entrypoint.sh,.dockerignore,.env.example,.env.prod.example} my-app/
cd my-appAdding Docker to an existing Strav project — run the cp command above from your project root.
cp .env.example .envEdit .env and set at minimum:
APP_KEY= # required — generate with: openssl rand -base64 32All other values in .env.example are pre-set for the local Docker network and work out of the box.
# Web server + PostgreSQL + Caddy (minimum)
docker compose up
# Also start the queue worker and Redis
docker compose --profile worker up
# Everything (web, db, redis, worker, scheduler)
docker compose --profile full upThe app is available at http://localhost (Caddy proxies to the Bun process on port 3000).
Caddy issues a certificate from its own local CA on first boot. To avoid browser warnings you need to install that CA on the host machine — running caddy trust inside the container only affects the container's trust store, not your browser.
macOS
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt caddy-root.crt
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt
rm caddy-root.crtLinux (Debian/Ubuntu)
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt caddy-root.crt
sudo cp caddy-root.crt /usr/local/share/ca-certificates/caddy-local.crt
sudo update-ca-certificates
rm caddy-root.crtAfter installing, fully quit and reopen your browser (a new tab is not enough — Chrome and Safari reload the trust store only on restart).
The site is then available at https://localhost. The certificate persists in the caddy_data volume, so you only need to do this once per machine.
Migrations run automatically when the web container starts. To run them manually:
docker compose exec web bun strav migrateOther database commands:
docker compose exec web bun strav generate:migration # create a new migration
docker compose exec web bun strav rollback # revert last migration
docker compose exec web bun strav fresh # drop all tables and rebuild
docker compose exec web bun strav seed # seed test dataThe entire project directory is mounted into the web container (bun run dev uses Bun's built-in file watcher). Save a file — the process restarts automatically. Node modules are isolated in a named volume so host node_modules/ never interferes.
docker compose exec web bun testAny Linux host with Docker Engine 24+ works. A single VM with 2 vCPUs / 2 GB RAM handles modest traffic comfortably; add more web or worker replicas as needed.
cp .env.prod.example .env.prodFill in every value — none have safe defaults in production:
| Variable | Notes |
|---|---|
APP_KEY |
Generate with openssl rand -base64 32. Rotate with care. |
APP_DOMAIN |
The public domain (example.com). DNS A record must point here before first boot. |
DB_USER / DB_PASSWORD / DB_DATABASE |
Use a strong password. |
REDIS_PASSWORD |
Required — the Redis service is started with --requirepass. |
REDIS_URL |
Pre-filled in example as redis://:${REDIS_PASSWORD}@redis:6379. |
docker compose -f docker-compose.yml -f docker-compose.prod.yml buildThis runs the multi-stage build (deps → builder → runner) and tags the result as strav-app:latest. All three app services (web, worker, scheduler) share this single image.
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dOn first boot, Compose starts the services in dependency order:
dbandredis(with healthchecks)migrate— runsbun strav migrate, then exits cleanlyweb(×2),worker(×2),scheduler(×1) — start aftermigratecompletes
Caddy automatically obtains a Let's Encrypt TLS certificate on the first request. No manual certificate management needed.
The Caddy service serves files from ./public/ on the host. If you check out the source code on the server, this directory is populated by the build step. For image-only deploys (no source checkout), copy assets out of the builder stage in CI:
docker create --name tmp strav-app:latest
docker cp tmp:/app/public ./public
docker rm tmp# Pull latest code / new image
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
# Roll the stack — migrate runs first automatically
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dZero-downtime updates rely on the migrate init container completing before new web replicas start. Old replicas continue serving traffic while new ones start.
# Follow logs for all services
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f
# Follow only the app
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f web worker scheduler
# Check health status
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps| Profile | Extra services started |
|---|---|
| (none) | db, caddy, web |
worker |
+ redis, worker |
scheduler |
+ scheduler |
full |
+ redis, worker, scheduler |
In production all services (including worker and scheduler) start unconditionally — profiles are development-only.
| Variable | Default (dev) | Description |
|---|---|---|
APP_ENV |
local |
local or production |
APP_KEY |
(empty) | Application encryption key. Generate with openssl rand -base64 32. |
APP_DOMAIN |
localhost |
Public hostname. Used by Caddy for TLS and virtual hosting. |
APP_URL |
http://localhost |
Full base URL. Required by Strav's HTTP config. Use https:// in production. |
| Variable | Default (dev) | Description |
|---|---|---|
DB_HOST |
db |
Postgres hostname (Docker service name). |
DB_PORT |
5432 |
Postgres port. |
DB_USER |
postgres |
Postgres user. |
DB_PASSWORD |
postgres |
Postgres password. Change in production. |
DB_DATABASE |
my_app |
Database name. |
| Variable | Default (dev) | Description |
|---|---|---|
REDIS_URL |
redis://redis:6379 |
Full Redis connection URL. |
REDIS_PASSWORD |
(prod only) | Redis requirepass value. Required in production. |
| Variable | Values | Description |
|---|---|---|
STRAV_PROCESS |
web, worker, scheduler, migrate |
Controls what the entrypoint starts. Set automatically by Compose per service. |
# Open a shell in the running web container
docker compose exec web sh
# Generate a new application key
openssl rand -base64 32
# Generate models from the current schema
docker compose exec web bun strav generate:models
# Retry failed queue jobs
docker compose exec worker bun strav queue:retry
# Run only migrations (no server start)
docker compose run --rm -e STRAV_PROCESS=migrate web
# Connect to Postgres directly
docker compose exec db psql -U postgres -d my_app
# Connect to Redis
docker compose exec redis redis-cliThe Dockerfile uses three stages:
| Stage | Base | Purpose |
|---|---|---|
deps |
oven/bun:1-alpine |
Installs production-only dependencies (--production). |
builder |
oven/bun:1-alpine |
Installs all dependencies, copies source, runs bun run build. |
runner |
oven/bun:1-alpine |
Copies app source from builder and prod node_modules from deps. Runs as a non-root system user (strav, uid 1001). |
The runner stage is the production image. Dev compose uses the builder stage directly with a source mount, so hot-reload works without rebuilding.
The docker-entrypoint.sh script probes Postgres before starting the selected process. It handles four modes via STRAV_PROCESS:
| Mode | Behaviour |
|---|---|
web |
Runs bun strav migrate, then hands off to CMD |
migrate |
Runs bun strav migrate and exits — CMD is skipped |
worker |
Starts immediately, skips migrations |
scheduler |
Starts immediately, skips migrations |
In production the migrate Compose service runs in migrate mode as an init container. The web replicas declare depends_on: migrate: condition: service_completed_successfully, ensuring migrations finish exactly once before any HTTP traffic is accepted.