Skip to content

Services

Every Holden app has a services: block. Each service becomes one container. An app can have one service or many - a web server alone, or a web server plus workers and schedulers grouped together with shared networking and needs.

holden.yml
services:
web:
image: myapp:latest
domain: app.example.com
port: 3000
env:
DATABASE_URL: ${needs.postgres.url}
worker:
image: myapp:latest
command: ["bun", "worker.ts"]
env:
DATABASE_URL: ${needs.postgres.url}
needs:
postgres:

This creates two containers:

  • myapp-web - serves HTTP traffic
  • myapp-worker - runs background jobs

Both share the same network (holden-myapp) and the same Postgres instance.

Services with domain: get Traefik routing. They need a port: to tell Traefik where to forward traffic.

services:
web:
image: myapp:latest
domain: app.example.com
port: 3000

Services without domain: are workers. They run in the background, typically processing queues or running scheduled tasks.

services:
worker:
image: myapp:latest
command: ["bun", "worker.ts"]

Workers can still connect to needs and other services on the app network.

A service can have multiple domains:

services:
web:
image: myapp:latest
domain:
- app.example.com
- www.example.com
port: 3000

By default, all domains redirect to the first one. See Networking for more options.

Needs are defined at the app level, not per-service. All services in an app share the same needs.

services:
web:
image: myapp:latest
env:
DATABASE_URL: ${needs.postgres.url}
REDIS_URL: ${needs.valkey.url}
worker:
image: myapp:latest
env:
DATABASE_URL: ${needs.postgres.url} # Same database
REDIS_URL: ${needs.valkey.url} # Same Valkey
needs:
postgres:
valkey:

Services in the same app can reach each other by name:

services:
web:
image: myapp:latest
env:
WORKER_URL: http://worker:8080
worker:
image: myapp:latest
port: 8080

The hostname is the service name (web, worker). No full container name needed.

Volumes are stored at the app level, so services can share them:

services:
web:
volumes:
- ./uploads:/app/uploads # → /appdata/myapp/uploads
worker:
volumes:
- ./uploads:/app/uploads # Same directory
- ./cache:/worker/cache # → /appdata/myapp/cache

See Volumes for more details on paths and permissions.

Set memory and CPU limits per service:

services:
web:
image: myapp:latest
memory: 512m
cpu: 0.5

If not set, containers run without limits. See the holden.yml reference for all options.

holden.yml
services:
web:
image: ghcr.io/myorg/myapp:latest
domain: app.example.com
port: 3000
env:
DATABASE_URL: ${needs.postgres.url}
REDIS_URL: ${needs.valkey.url}
S3_ENDPOINT: ${needs.garage.url}
worker:
image: ghcr.io/myorg/myapp:latest
command: ["bun", "run", "worker"]
env:
DATABASE_URL: ${needs.postgres.url}
REDIS_URL: ${needs.valkey.url}
scheduler:
image: ghcr.io/myorg/myapp:latest
command: ["bun", "run", "scheduler"]
env:
DATABASE_URL: ${needs.postgres.url}
needs:
postgres:
valkey:
garage:

This deploys:

  • Web server at app.example.com
  • Background worker processing Valkey queues
  • Scheduler running cron jobs
  • PostgreSQL, Valkey, and Garage as supporting infrastructure

All containers share one network and can communicate freely.