Skip to main content
Stackbastion

Draft, not yet published

A Docker Compose setup for AI-generated apps that actually works in production

7 July 2026· 4 min read · by Stackbastion

You copied a docker-compose.yml off a tutorial, it ran locally, and now you’re wondering if it’s safe to point real users at it. Probably not yet. The tutorial version is missing the boring parts that keep an app up: restart policies, health checks, persistent volumes, and a database that won’t fall over under load. Here’s a Compose setup that covers them.

Why the tutorial version breaks in production

Most starter Compose files are written to demo the app once, not to run it for months. They leave out four things, and each one bites in a specific way.

No restart policy means a single crash takes your app down until you manually restart it. No health check means Docker thinks a hung container is fine and keeps routing traffic to it. No named volume means your database gets wiped the next time you run docker compose down. And connecting your app straight to Postgres means the database runs out of connections the moment you get a traffic spike.

None of these show up in a local demo. All of them show up in production. Let’s write a file that handles them.

How to fix it

Here’s a full stack: your app, Postgres, PgBouncer in front of the database, and Caddy for HTTPS. Comments call out why each piece is there.

services:
  web:
    build: .
    restart: unless-stopped        # come back after a crash or reboot
    env_file: .env
    depends_on:
      db:
        condition: service_healthy # don't start until Postgres is ready
    expose:
      - "3000"                     # private to the Docker network
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data   # data survives restarts
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
      interval: 10s
      timeout: 5s
      retries: 5

  pgbouncer:
    image: edoburu/pgbouncer:latest
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@db:5432/$POSTGRES_DB"
      POOL_MODE: transaction
      MAX_CLIENT_CONN: 200
      DEFAULT_POOL_SIZE: 20
    depends_on:
      db:
        condition: service_healthy
    expose:
      - "6432"

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data

volumes:
  pgdata:
  caddy_data:

A few things worth understanding, not just pasting.

The app connects to PgBouncer, not Postgres

Your app’s DATABASE_URL should point at pgbouncer:6432, not db:5432. PgBouncer pools connections so a hundred users share a handful of real database connections. Skip this and a modest traffic spike exhausts Postgres and every request starts failing. Full explanation in why your app needs PgBouncer.

The health check needs a real endpoint

The web health check hits /api/health. You have to actually build that route. It can be three lines that return 200 OK:

// app/api/health/route.js  (Next.js example)
export function GET() {
  return new Response('ok', { status: 200 });
}

Without it, Docker’s health check always fails and depends_on never lets dependents start.

expose vs ports

Only Caddy uses ports, which publishes to the internet. Everything else uses expose, which is reachable only inside the Docker network. This means nobody can hit your database or app directly, only through Caddy over HTTPS. Getting this backwards is how databases end up open to the whole internet.

The Caddyfile

One file, and HTTPS is handled:

app.yourdomain.com {
    reverse_proxy web:3000
}

Bring it all up and watch the health checks go green:

docker compose up -d --build
docker compose ps          # STATUS column should say "healthy"

Or, we do it for you

We run this pattern for customers so they never have to reason about pool sizes and health checks at 3am. If you’d rather ship features than tune Compose files, book a free audit and we’ll review your setup.

FAQ

Should Postgres run in Docker at all for production?

For a small app, yes, as long as you use a named volume and have tested backups. The volume keeps your data across restarts, and backups keep it across disasters. At larger scale some teams move the database to a dedicated machine or managed service, but a containerized Postgres with real backups serves most apps well.

What does restart: unless-stopped actually do?

It tells Docker to restart the container if it crashes or the server reboots, but not if you deliberately stopped it. It’s the sane default for production. The alternative, always, will even restart containers you meant to keep off, which gets annoying.

Why not just increase Postgres max_connections instead of using PgBouncer?

Each Postgres connection costs real memory, so raising the limit trades one problem for out-of-memory crashes. Pooling with PgBouncer lets many clients share few connections, which is far cheaper and more stable. The pooling post shows the math.

How do I run database migrations with this setup?

Run them as a one-off command against the running stack, for example docker compose exec web pnpm migrate. Point migrations at Postgres directly (db:5432), not through PgBouncer, since some migration tools need session-level features that transaction pooling blocks.