Docker Compose
Compose runs the service and what it depends on — here, PostgreSQL — as one stack. The service runs on the esrun image, ghcr.io/open-tech-foundation/esrun: either built into an image of your own (build: .) or used as it is, with the bundle mounted in.
The app
A visit counter. runtime:db speaks PostgreSQL itself, so no package is needed:
// src/server.ts import { serve } from "runtime:http"; import { connect, postgres, sql } from "runtime:db"; import { env } from "runtime:process"; const db = await connect(env.DATABASE_URL, { driver: postgres, pool: true }); await db.execute("CREATE TABLE IF NOT EXISTS visits (at timestamptz NOT NULL DEFAULT now())"); serve({ port: Number(env.PORT ?? 8080) }, async (request) => { if (new URL(request.url).pathname === "/health") return new Response("ok"); await db.execute(sql`INSERT INTO visits DEFAULT VALUES`); const { n } = await (await db.query("SELECT count(*)::int AS n FROM visits")).first(); return Response.json({ visits: n }); });
compose.yaml
build: . builds the app's own image from the two-stage Dockerfile in Containers, whose run stage is the esrun image:
FROM ghcr.io/open-tech-foundation/esrun:latest COPY --from=build /app/dist ./dist
services: app: build: . ports: - "8080:8080" environment: PORT: "8080" DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app command: - --allow-listen=8080 - --allow-net=db:5432 - --allow-env=PORT,DATABASE_URL - --shutdown-grace=8000 - dist/server.js depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "esrun", "--allow-net=127.0.0.1:8080", "-e=const r = await fetch('http://127.0.0.1:8080/health'); if (!r.ok) throw new Error('status ' + r.status);"] interval: 10s timeout: 3s stop_grace_period: 10s mem_limit: 512m restart: unless-stopped db: image: postgres:17 environment: POSTGRES_USER: app POSTGRES_DB: app POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "app", "-d", "app"] interval: 2s timeout: 3s retries: 15 restart: unless-stopped volumes: pgdata:
And a .env beside it, kept out of version control:
POSTGRES_PASSWORD=change-me
docker compose up -d --build --wait
Without a Dockerfile
For a quick stack, skip the app image: build the bundle with esdev build --minify, then run the esrun image directly with dist/ mounted read-only. In the app service, replace build: . with:
image: ghcr.io/open-tech-foundation/esrun:latest volumes: - ./dist:/app/dist:ro
Everything else stays the same: the image's entrypoint is esrun, so command: holds its arguments, and its working directory is /app. A deployment that ships to other machines should prefer build: ., so the bundle travels inside an image with a version of its own.
Grants name services
Inside the stack, services reach each other by name. The grant names the service and port, and nothing else is reachable:
command: - --allow-net=db:5432
Leave it out and the app fails at startup, when it connects, with NotAllowedError: capability denied: Net. A call to anything other than db:5432 fails the same way.
Start order
depends_on with condition: service_healthy holds the app back until PostgreSQL answers pg_isready, so the app's first connection does not race the database's startup. --wait returns once both report healthy.
Compose replaces ${NAME} anywhere in compose.yaml, including inside an inline script. Write $${…} for a literal one, or avoid template literals in health-check code as above.
Health checks
The health check calls its own route, /health, so checks every ten seconds do not count as visits. It is esrun making the request, granted only that one address — the image has no curl.
Shutdown
docker compose stop and down send SIGTERM. esrun drains in-flight requests for up to --shutdown-grace (8 seconds here) and exits with 143, inside stop_grace_period (10 seconds), after which Compose sends SIGKILL.
Memory
mem_limit sets the container's limit, and esrun sizes its heap from it. See Memory.
Data
PostgreSQL's data lives in the pgdata volume, so docker compose down and up keep it. docker compose down -v deletes it.