Skip to main content
BunShip includes a production-ready Docker setup with a multi-stage Dockerfile, development and production Compose files, and health checks for every service.

Dockerfile Overview

The Dockerfile at docker/Dockerfile.api uses four stages to produce a small, secure image:
StagePurposeWhat happens
baseShared foundationInstalls dumb-init on oven/bun:1.1.38-alpine
depsProduction dependenciesRuns bun install --frozen-lockfile --production
builderCompile stepInstalls all deps, copies source, runs bun run build
runnerFinal imageCopies only production deps + built output, runs as non-root user
Key security properties of the final image:
  • Runs as user bunship (UID 1001), not root
  • Alpine base for minimal attack surface (~5 MB base layer)
  • dumb-init as PID 1 for proper signal handling
  • Built-in HEALTHCHECK against /health
# Final stage (simplified)
FROM oven/bun:1.1.38-alpine AS runner
ENV NODE_ENV=production
RUN adduser -S bunship -u 1001
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./apps/api/dist
USER bunship
HEALTHCHECK --interval=30s --timeout=3s CMD bun fetch http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["bun", "run", "apps/api/dist/index.js"]

Development with Docker Compose

The development Compose file (docker/docker-compose.yml) starts three services: the API, a background worker, and Redis.
# Build and start all services
docker-compose -f docker/docker-compose.yml up --build

# Start in the background
docker-compose -f docker/docker-compose.yml up -d

# View API logs
docker-compose -f docker/docker-compose.yml logs -f api

# Stop everything
docker-compose -f docker/docker-compose.yml down
In development mode, source directories are mounted as read-only volumes so code changes reflect without rebuilding:
volumes:
  - ../apps/api/src:/app/apps/api/src:ro
  - ../packages:/app/packages:ro

Services

Runs the Elysia API on port 3000. Depends on Redis being healthy before starting.
api:
  build:
    context: ..
    dockerfile: docker/Dockerfile.api
    target: runner
  ports:
    - "3000:3000"
  env_file:
    - ../.env
  environment:
    - NODE_ENV=development
    - REDIS_HOST=redis
    - REDIS_URL=redis://redis:6379
  depends_on:
    redis:
      condition: service_healthy

Production with Docker Compose

The production override file (docker/docker-compose.prod.yml) layers on top of the development file to add resource limits, replica counts, log rotation, and Redis authentication.
# Build the production image
docker build -f docker/Dockerfile.api -t bunship-api:latest .

# Start with production settings
docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  up -d

What changes in production

SettingDevelopmentProduction
NODE_ENVdevelopmentproduction
Source mountsMounted for hot reloadRemoved (code baked into image)
Redis authNo passwordREDIS_PASSWORD required
API replicas12 (configurable)
Resource limitsNoneCPU and memory capped
Log rotationDefault50 MB max, 5 files
Restart policyunless-stoppedalways
Rolling updatesN/AStart-first with 10s delay

Resource Limits

ServiceCPU LimitMemory LimitCPU ReservationMemory Reservation
API1.01 GB0.5512 MB
Worker1.01 GB0.5512 MB
Redis0.5512 MB0.25256 MB
Adjust these in docker-compose.prod.yml based on your workload.

Building and Running

Build the Image

# Standard build
docker build -f docker/Dockerfile.api -t bunship-api:1.0.0 .

# Build with BuildKit cache (faster rebuilds)
DOCKER_BUILDKIT=1 docker build \
  --cache-from bunship-api:latest \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -f docker/Dockerfile.api \
  -t bunship-api:1.0.0 .

Run Database Migrations

Run migrations before starting the application for the first time, or after schema changes:
docker run --rm \
  --env-file .env \
  bunship-api:1.0.0 \
  bun run db:migrate

Push to a Registry

# GitHub Container Registry
docker tag bunship-api:1.0.0 ghcr.io/your-org/bunship-api:1.0.0
docker push ghcr.io/your-org/bunship-api:1.0.0

# AWS ECR
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin <account>.dkr.ecr.us-east-1.amazonaws.com
docker tag bunship-api:1.0.0 <account>.dkr.ecr.us-east-1.amazonaws.com/bunship-api:1.0.0
docker push <account>.dkr.ecr.us-east-1.amazonaws.com/bunship-api:1.0.0

Volume Management

BunShip uses two named volumes:
VolumeMount PointPurpose
redis-data/dataRedis AOF persistence
db-data/app/dataLocal SQLite database (development only)
The db-data volume is only relevant when using a file-based SQLite database (TURSO_DATABASE_URL=file:../../local.db). In production with Turso Cloud, no local database volume is needed.
# List volumes
docker volume ls | grep bunship

# Back up Redis data
docker run --rm -v bunship_redis-data:/data -v $(pwd):/backup alpine \
  tar czf /backup/redis-backup.tar.gz -C /data .

# Remove volumes (destroys data)
docker-compose -f docker/docker-compose.yml down -v

Environment Variables

Pass environment variables through an .env file or your orchestrator’s secrets system.
NODE_ENV=development
API_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
TURSO_DATABASE_URL=file:../../local.db
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URL=redis://redis:6379
JWT_SECRET=dev-secret-change-in-production-min-32
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
Inside Docker Compose, service-level environment entries override values from env_file. The Compose files set REDIS_HOST=redis so the API connects to the Redis container by service name rather than localhost.

Scaling

Multiple API Instances

Scale the API horizontally with Docker Compose:
# Run 3 API instances
docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  up -d --scale api=3
When running multiple instances, place a reverse proxy (Nginx, Caddy, or Traefik) in front to distribute traffic. BunShip is stateless — sessions are validated via JWT and jobs are coordinated through Redis — so any instance can handle any request.
api.yourdomain.com {
    reverse_proxy bunship-api-1:3000 bunship-api-2:3000 bunship-api-3:3000 {
        lb_policy round_robin
        health_uri /health
        health_interval 30s
    }
}

Worker Scaling

Scale workers independently from the API:
docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  up -d --scale worker=2
BullMQ distributes jobs across worker instances automatically. Add workers when your queue depth grows or job processing time increases.

Zero-Downtime Updates

The production Compose file configures rolling updates with a start-first strategy:
deploy:
  update_config:
    parallelism: 1
    delay: 10s
    order: start-first
  rollback_config:
    parallelism: 1
    delay: 5s
To deploy a new version:
# Pull or build the new image
docker build -f docker/Dockerfile.api -t bunship-api:1.1.0 .

# Update running services (zero downtime)
docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  up -d --no-deps --build api

# Rollback if something goes wrong
docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  stop api

docker tag bunship-api:1.0.0 bunship-api:latest

docker-compose \
  -f docker/docker-compose.yml \
  -f docker/docker-compose.prod.yml \
  up -d api

Troubleshooting

Container won’t start

# Check logs for error output
docker-compose -f docker/docker-compose.yml logs api

# Inspect the container for config issues
docker inspect bunship-api

# Verify environment variables are set
docker-compose -f docker/docker-compose.yml exec api env | sort

Redis connection refused

# Confirm Redis is healthy
docker-compose -f docker/docker-compose.yml ps redis

# Test connection from inside the network
docker-compose -f docker/docker-compose.yml exec api \
  bun -e "const r = require('ioredis'); new r('redis://redis:6379').ping().then(console.log)"

Build is slow

Enable BuildKit and layer caching:
export DOCKER_BUILDKIT=1
docker build \
  --cache-from bunship-api:latest \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -f docker/Dockerfile.api \
  -t bunship-api:latest .
The Dockerfile copies package.json and bun.lockb before source code, so dependency installation is cached unless lockfile changes.