Tutorial

Docker Compose Patterns: 15 Production-Ready Stacks You Can Deploy Today

Back to Blog
A modern alternative to cPanel, Plesk and CyberPanel — isolated, secure, AI-assisted.
Start free

What Is a Docker Compose Pattern?

A Docker Compose pattern is a reusable docker-compose.yml template that solves one specific hosting problem: running a CMS with its database and cache, putting a reverse proxy in front of several apps, scheduling automated backups, or capping a container's memory before it takes down the host. Instead of writing service definitions from scratch every time, you copy the pattern, adjust the environment variables and volumes, and deploy.

This post is a recipe collection, not a tutorial. If you are still learning Compose syntax, service dependencies, networking, or volumes, read our Docker Compose tutorial first. That post teaches the concepts. This one assumes you already know them and gives you fifteen ready-to-copy stacks: real images, real environment variables, and the operational notes that separate a demo from something you would actually run on a production server.

Every example below uses the current Compose Specification syntax. There is no version: key at the top of the file — that field was deprecated years ago and modern docker compose ignores it. If you see a version: "3.8" line in an old tutorial, delete it.

1. WordPress + MariaDB + Redis Object Cache

The most common stack on the internet, done properly: PHP-FPM behind its own web server, a dedicated database, and a Redis object cache so every page load does not hammer MySQL with the same queries.

services:
  wordpress:
    image: wordpress:6-php8.3-fpm-alpine
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', 'cache');
        define('WP_CACHE', true);
    volumes:
      - wp_data:/var/www/html
    secrets:
      - db_password
    networks:
      - wp_net

  db:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD_FILE: /run/secrets/db_password
      MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    volumes:
      - db_data:/var/lib/mysql
    secrets:
      - db_password
      - db_root_password
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - wp_net

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    networks:
      - wp_net

volumes:
  wp_data:
  db_data:

secrets:
  db_password:
    file: ./secrets/db_password.txt
  db_root_password:
    file: ./secrets/db_root_password.txt

networks:
  wp_net:

Operational note: pair this with a WordPress-aware caching plugin (Redis Object Cache) so the WP_REDIS_HOST define is actually used — installing Redis alone does nothing without the plugin telling WordPress to talk to it.

2. PostgreSQL + pgAdmin for a Managed Database Endpoint

Use this when an application needs Postgres but you also want a web UI to inspect tables without SSH-ing in and running psql by hand.

services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
    volumes:
      - pg_data:/var/lib/postgresql/data
    secrets:
      - pg_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - db_net

  pgadmin:
    image: dpage/pgadmin4:8
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      PGADMIN_DEFAULT_EMAIL: [email protected]
      PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
    volumes:
      - pgadmin_data:/var/lib/pgadmin
    secrets:
      - pgadmin_password
    ports:
      - "127.0.0.1:5050:80"
    networks:
      - db_net

volumes:
  pg_data:
  pgadmin_data:

secrets:
  pg_password:
    file: ./secrets/pg_password.txt
  pgadmin_password:
    file: ./secrets/pgadmin_password.txt

networks:
  db_net:

Operational note: bind pgAdmin to 127.0.0.1 and reach it over an SSH tunnel or your reverse proxy — do not publish port 5050 to the public internet directly.

3. Node.js API + PostgreSQL With Startup Ordering

The classic mistake with a Node app and a database in the same stack is the app crashing on boot because Postgres was not ready yet. A healthcheck-gated depends_on fixes it without a custom wait script.

services:
  api:
    build: ./api
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
      NODE_ENV: production
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 20s
    networks:
      - api_net

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - api_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - api_net

volumes:
  api_db_data:

networks:
  api_net:

Operational note: DB_PASSWORD comes from a .env file in the same directory as the compose file — Compose loads it automatically, so it never has to be typed into the YAML itself.

4. Nginx Reverse Proxy in Front of Multiple Apps

Use this when several services need to share one public IP and port 80/443, routed by hostname. This is the pattern behind almost every multi-app VPS.

services:
  proxy:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    depends_on:
      - app_one
      - app_two
    networks:
      - proxy_net

  app_one:
    image: traefik/whoami
    restart: unless-stopped
    networks:
      - proxy_net

  app_two:
    image: traefik/whoami
    restart: unless-stopped
    networks:
      - proxy_net

networks:
  proxy_net:

The routing lives in nginx/conf.d/default.conf, mounted read-only into the proxy container:

server {
    listen 80;
    server_name one.example.com;
    location / {
        proxy_pass http://app_one:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
server {
    listen 80;
    server_name two.example.com;
    location / {
        proxy_pass http://app_two:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Operational note: service names (app_one, app_two) resolve through Docker's built-in DNS as long as every container shares the same network — see our Docker networking guide for how bridge networks and DNS resolution actually work under the hood.

5. Static Site With an Edge Cache Layer

When an origin (WordPress, a CMS, or a slow API) sits behind Nginx, adding a proxy cache in front of it cuts origin requests dramatically for content that does not change every request.

services:
  cache:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/cache.conf:/etc/nginx/conf.d/default.conf:ro
      - nginx_cache:/var/cache/nginx
    depends_on:
      - origin
    networks:
      - site_net

  origin:
    image: nginx:1.27-alpine
    restart: unless-stopped
    volumes:
      - ./site:/usr/share/nginx/html:ro
    networks:
      - site_net

volumes:
  nginx_cache:

networks:
  site_net:

cache.conf defines the cache zone and forwards to the origin container:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=edge:10m max_size=1g inactive=60m;

server {
    listen 80;
    location / {
        proxy_cache edge;
        proxy_cache_valid 200 10m;
        proxy_pass http://origin:80;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

Operational note: the X-Cache-Status header (HIT, MISS, EXPIRED) is the fastest way to confirm the cache is actually working — check it with curl -I before assuming anything is cached.

6. Nextcloud + PostgreSQL + Redis

Self-hosted file sync and collaboration. Postgres backs the metadata, Redis handles file locking and caching so multiple simultaneous uploads do not corrupt the lock table.

services:
  nextcloud:
    image: nextcloud:29-apache
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD_FILE: /run/secrets/nc_db_password
      REDIS_HOST: cache
      NEXTCLOUD_TRUSTED_DOMAINS: files.example.com
    volumes:
      - nc_data:/var/www/html
    secrets:
      - nc_db_password
    networks:
      - nc_net

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD_FILE: /run/secrets/nc_db_password
    volumes:
      - nc_db_data:/var/lib/postgresql/data
    secrets:
      - nc_db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U nextcloud"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - nc_net

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    networks:
      - nc_net

volumes:
  nc_data:
  nc_db_data:

secrets:
  nc_db_password:
    file: ./secrets/nc_db_password.txt

networks:
  nc_net:

Operational note: set NEXTCLOUD_TRUSTED_DOMAINS to the real hostname before first login — Nextcloud rejects requests from hostnames it does not recognize, which is the single most common "why can't I log in" support question.

7. Gitea + PostgreSQL

A self-hosted Git server for a team or personal projects. Lighter than running your own GitLab instance, with the same core Git-over-HTTP/SSH workflow.

services:
  gitea:
    image: gitea/gitea:1.23
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      GITEA__database__DB_TYPE: postgres
      GITEA__database__HOST: db:5432
      GITEA__database__NAME: gitea
      GITEA__database__USER: gitea
      GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
    volumes:
      - gitea_data:/data
    ports:
      - "3080:3000"
      - "2222:22"
    networks:
      - gitea_net

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: gitea
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
    volumes:
      - gitea_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - gitea_net

volumes:
  gitea_data:
  gitea_db_data:

networks:
  gitea_net:

Operational note: mapping port 2222 to container port 22 keeps Gitea's internal SSH server off the host's own port 22, so your regular server SSH access is untouched.

8. n8n Workflow Automation + PostgreSQL

An open-source alternative to Zapier-style automation, running entirely on your own server with your own data.

services:
  n8n:
    image: n8nio/n8n:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: db
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD}
      N8N_BASIC_AUTH_ACTIVE: "true"
      N8N_BASIC_AUTH_USER: admin
      N8N_BASIC_AUTH_PASSWORD: ${N8N_ADMIN_PASSWORD}
      GENERIC_TIMEZONE: UTC
    volumes:
      - n8n_data:/home/node/.n8n
    ports:
      - "5678:5678"
    networks:
      - n8n_net

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: n8n
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${N8N_DB_PASSWORD}
    volumes:
      - n8n_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - n8n_net

volumes:
  n8n_data:
  n8n_db_data:

networks:
  n8n_net:

Operational note: the default setup uses SQLite if you skip the DB_TYPE variables entirely — fine for testing a handful of workflows, but switch to Postgres before you have anything that matters running on a schedule.

9. Uptime Kuma, Standalone

A single-container uptime monitor with a clean dashboard — no database service needed, it ships with its own SQLite store.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: unless-stopped
    volumes:
      - kuma_data:/app/data
    ports:
      - "3001:3001"

volumes:
  kuma_data:

Operational note: this is the one pattern in this list that genuinely needs nothing else — no database, no cache, no reverse proxy required for a single-user setup. Resist the urge to over-engineer it.

10. Immich Photo Library (App + Database + Cache + Machine Learning)

A self-hosted Google Photos alternative. The full stack has more moving parts than most self-hosted apps because it includes a machine learning container for face and object recognition.

services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:release
    restart: unless-stopped
    depends_on:
      - db
      - redis
    environment:
      DB_HOSTNAME: db
      DB_USERNAME: immich
      DB_PASSWORD: ${IMMICH_DB_PASSWORD}
      DB_DATABASE_NAME: immich
      REDIS_HOSTNAME: redis
    volumes:
      - immich_uploads:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "2283:2283"
    networks:
      - immich_net

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:release
    restart: unless-stopped
    volumes:
      - immich_ml_cache:/cache
    networks:
      - immich_net

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    networks:
      - immich_net

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: immich
      POSTGRES_USER: immich
      POSTGRES_PASSWORD: ${IMMICH_DB_PASSWORD}
    volumes:
      - immich_db_data:/var/lib/postgresql/data
    networks:
      - immich_net

volumes:
  immich_uploads:
  immich_ml_cache:
  immich_db_data:

networks:
  immich_net:

Operational note: immich_uploads grows fast once photo backup starts — put this volume on a disk with real headroom, not the same partition as your OS.

11. Redis + Background Worker Queue

Any app that needs to offload slow work (sending email, resizing images, generating reports) out of the request/response cycle needs a queue. This is the minimal shape of that pattern.

services:
  queue:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - queue_data:/data
    networks:
      - worker_net

  worker:
    build: ./worker
    restart: unless-stopped
    depends_on:
      - queue
    environment:
      REDIS_URL: redis://queue:6379
      WORKER_CONCURRENCY: "4"
    deploy:
      replicas: 2
    networks:
      - worker_net

volumes:
  queue_data:

networks:
  worker_net:

Operational note: redis-server --appendonly yes persists the queue to disk so an unfinished job is not silently lost if the container restarts mid-task. Without it, an in-flight job in Redis's memory disappears on restart.

12. Healthcheck and Restart Policy Pattern

This is not a standalone stack — it is the snippet you should be adding to every service above. A container that "runs" but never answers requests is worse than one that crashes cleanly, because docker ps shows it as healthy when it is not.

services:
  app:
    image: your-app:latest
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s

Operational note: restart: unless-stopped is almost always the right choice over always — it respects a deliberate docker compose stop instead of fighting you by restarting a container you intentionally shut down. Naive depends_on without a healthcheck condition is a common source of race-condition crashes on container startup.

13. Resource Limit Pattern

One runaway container should never be able to take down every other service on the same host. Cap memory and CPU on every service, not just the ones you suspect will misbehave.

services:
  app:
    image: your-app:latest
    restart: unless-stopped
    mem_limit: 512m
    mem_reservation: 256m
    cpus: "0.50"
    pids_limit: 200

Operational note: mem_limit/cpus work directly with plain docker compose up — you do not need Swarm mode for basic resource caps, that is a common misconception carried over from older Compose documentation.

14. Secrets and Environment Management Pattern

Passwords typed directly into environment: end up in docker inspect output, in your shell history if you ever ran the equivalent command manually, and potentially in a Git repository if the compose file gets committed. File-based secrets avoid all three.

services:
  app:
    image: your-app:latest
    restart: unless-stopped
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Operational note: add secrets/ to .gitignore immediately, before the first commit — not after. See our Docker security guide for the wider picture on least-privilege containers, and our rootless Docker guide if you want to remove the root daemon from the equation entirely.

15. Backup Sidecar Pattern

A database with no automated backup is a ticking clock, not a database. This pattern adds a lightweight scheduler container that runs pg_dump on a cron schedule without installing cron on the host or inside the database image itself.

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backup_net

  backup:
    image: mcuadros/ofelia:latest
    restart: unless-stopped
    depends_on:
      - db
    command: daemon --docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./backups:/backups
    labels:
      ofelia.job-exec.pg-dump.schedule: "0 0 3 * * *"
      ofelia.job-exec.pg-dump.command: >
        sh -c "PGPASSWORD=${DB_PASSWORD} pg_dump -h db -U appuser appdb
        > /backups/appdb-$(date +%Y%m%d).sql"
    networks:
      - backup_net

volumes:
  db_data:

networks:
  backup_net:

Operational note: a backup you have never restored is a theory, not a backup. Test the restore path (psql < appdb-20260101.sql against a throwaway container) on a schedule, not just the dump job itself.

Copying These Into Production

A few things apply across every pattern above:

  • Replace every plaintext ${VARIABLE} with a real value in a .env file next to the compose file, or switch to the file-based secrets: pattern from #14 for anything sensitive.
  • Named volumes (db_data:, wp_data:, and so on) survive docker compose down. Only docker compose down -v deletes them — know the difference before you run either command against a production stack.
  • Every stack here uses its own dedicated network so service name resolution stays predictable and containers from unrelated stacks cannot see each other by default.
  • Pin image tags to a major version (postgres:17-alpine, not postgres:latest) so an unrelated upstream update does not change your database version out from under you on a routine docker compose pull.

Running These Stacks on Panelica

Most of the applications covered here — WordPress, Nextcloud, Gitea, n8n, Uptime Kuma, Immich, PostgreSQL, pgAdmin, Redis, and more — are already available as one-click templates in Panelica's Docker Manager, which currently ships 176 pre-configured app templates covering CMS platforms, databases, monitoring tools, automation, and self-hosted alternatives to commercial SaaS products. A template handles the image pull, volume creation, and port mapping for you.

If you would rather write and manage your own docker-compose.yml files directly — which is exactly what this post assumes — Panelica's Docker Manager also supports importing and managing Compose stacks from the panel itself, alongside the same per-container resource controls (cgroups-based CPU and memory limits) shown in pattern #13. You get the copy-paste recipes from this post and a UI to watch logs, restart services, and adjust limits without touching the command line.

Frequently Asked Questions

Do these patterns require Docker Swarm?

No. Every example in this post runs with plain docker compose up -d on a single host. Swarm is a separate orchestration mode for running the same stack across multiple machines, and none of these patterns depend on it — including the resource limit pattern in #13, which is a common point of confusion carried over from older documentation that only described deploy.resources in a Swarm context.

Can I combine multiple patterns into one compose file?

Yes. Merge the services:, volumes:, and networks: blocks from two or more patterns into a single file, making sure every service name and volume name stays unique across the merged file. A common combination is the reverse proxy pattern (#4) sitting in front of several app stacks defined elsewhere in the same file.

What is the difference between this post and your Docker Compose tutorial?

Our Docker Compose tutorial teaches Compose from the ground up: syntax, networking, volumes, healthchecks, and multi-stage builds, using one WordPress example to illustrate each concept as it is introduced. This post assumes you already know that material and instead gives you fifteen separate, ready-to-copy production stacks for real applications.

How often should I update the image tags in these files?

Pin to a major version and update deliberately, not automatically. Run docker compose pull and review the changelog for that major version before applying it to a production stack — especially for anything with a database, where an unreviewed minor version bump can occasionally include a breaking migration.

Security-first hosting panel

Run your servers on a modern panel.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
Isolation, native.