Skip to content

Self-Hosted Dev Infrastructure with Docker Compose

Most dev setups run a separate Postgres, Redis, and MinIO for every project. Six containers doing the same job, different passwords, none of them shared. This post replaces that with one docker-compose.yml: a shared infra stack on a named Docker network that any app can join without owning or duplicating the services. By the end you'll have Traefik routing traffic, PostgreSQL and Redis running internally, MinIO handling object storage, and Mailpit catching all outbound email. Assumed knowledge: Docker basics and .env files.

Prerequisites

  • Docker Engine 24+
  • Docker Compose v2
  • Linux server or local machine (Ubuntu 22.04+ used throughout)
  • Traefik v3 already running as your reverse proxy

Versions used in this post

Traefik v3.2 · PostgreSQL 16 · Redis 7 · MinIO latest · Mailpit latest

Before you start

If you haven't set up Traefik yet, start with Traefik v3 as a Reverse Proxy. If this stack is going on a public VPS, read Securing a Public-Facing Dev Stack first. Docker bypasses UFW by default and a misconfigured port binding puts your database on the internet.


Background

The standard approach redeclares shared services in every project's Compose file. Tear down one app and Postgres stops. Start a new project and you reconfigure the same variables again.

The fix: one named network (infra_net) owns all shared services. App stacks declare it as external: true and connect by Docker DNS name (postgres:5432, redis:6379) without touching host ports. The infra stack runs independently of any app. Apps attach to it, they don't own it.

Docker bypasses UFW

Postgres and Redis have no application-layer firewall. Binding either to 0.0.0.0 on a public server means the port is reachable from the internet regardless of your UFW rules. Docker injects iptables rules directly. Always use 127.0.0.1:PORT:PORT for host port bindings on these services.


Implementation

1. Define the shared network and volumes

The name: infra_net field is required. Without it, Docker prepends the Compose project directory name to the network, and every external: true reference in your app stacks fails silently.

docker-compose.yml
networks:
  infra_net:
    driver: bridge
    name: infra_net

volumes:
  postgres_data:
  redis_data:
  minio_data:
  traefik_logs:
  mailpit_data:

2. Configure Traefik

docker-compose.yml
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    ports:
      - "0.0.0.0:80:80"
      - "127.0.0.1:8080:8080"
      - "0.0.0.0:8005:8005"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - traefik_logs:/logs
    networks:
      - infra_net
    labels:
      - "traefik.enable=false"

Port 8080 (dashboard) is bound to 127.0.0.1 only. Access it via SSH tunnel. Port 8005 is the public Mailpit entrypoint.

traefik.enable=false prevents Traefik from creating a route to its own container. Without it, a self-referential router appears in the dashboard.

3. Add PostgreSQL

docker-compose.yml
  postgres:
    image: postgres:16-alpine
    container_name: postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - infra_net
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 1
      start_period: 10s

start_period: 10s gives Postgres time to initialize before retries start counting. pg_isready checks whether the server accepts connections without running a query.

The host port (127.0.0.1:5432) exists for local tooling only: psql, DBeaver, pgAdmin. App containers connect to postgres:5432 over infra_net with no host port involved.

4. Add Redis

docker-compose.yml
  redis:
    image: redis:7-alpine
    container_name: redis
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --appendonly yes
      --appendfsync everysec
    volumes:
      - redis_data:/data
    networks:
      - infra_net
    ports:
      - "127.0.0.1:6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 1

--appendonly yes with --appendfsync everysec makes writes durable to within one second on crash. Without it, every container restart clears Redis entirely.

If Redis is purely an ephemeral cache with no sessions or task queues, drop the command block. The default configuration has lower overhead with no persistence.

5. Add MinIO

docker-compose.yml
  minio:
    image: minio/minio:latest
    container_name: minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    volumes:
      - minio_data:/data
    networks:
      - infra_net
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:9001:9001"
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 15s
      timeout: 5s
      retries: 1
      start_period: 15s

--console-address ":9001" pins the Console to a fixed port. Without it, MinIO binds the Console to a random port on every restart. The S3 API is always on 9000.

6. Add Mailpit

docker-compose.yml
  mailpit:
    image: axllent/mailpit:latest
    container_name: mailpit
    restart: unless-stopped
    ports:
      - "127.0.0.1:1025:1025"
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
      MP_UI_AUTH: ${MAILPIT_UI_AUTH}
      MP_LABEL: ${MAILPIT_LABEL:-Sofrosyn Mail}
      MP_DATABASE: /data/mailpit.db
    volumes:
      - mailpit_data:/data
    networks:
      - infra_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mailpit.rule=PathPrefix(`/`)"
      - "traefik.http.routers.mailpit.entrypoints=mail-server"
      - "traefik.http.services.mailpit.loadbalancer.server.port=8025"
    healthcheck:
      test: ["CMD", "/mailpit", "readyz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s

MP_SMTP_AUTH_ACCEPT_ANY: 1 accepts any SMTP credentials. This is intentional: you don't want email flows failing in dev because credentials don't match. MP_UI_AUTH applies to the Web UI only, not SMTP.

7. Write the environment file

.env
# Postgres
POSTGRES_USER=admin
POSTGRES_PASSWORD=changeme
POSTGRES_DB=postgres

# Redis
REDIS_PASSWORD=changeme

# MinIO
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=changeme

# Mailpit
MAILPIT_UI_AUTH=admin:changeme sofrosyn:changeme
MAILPIT_LABEL=Sofrosyn Mail

Add .env to .gitignore before your first commit.

8. Attach an app stack

app/docker-compose.yml
networks:
  infra_net:
    external: true

services:
  api:
    networks:
      - infra_net
    environment:
      DATABASE_URL: postgresql://admin:changeme@postgres:5432/postgres
      REDIS_URL: redis://:changeme@redis:6379/0
      MINIO_ENDPOINT: http://minio:9000
      SMTP_HOST: mailpit
      SMTP_PORT: 1025

Container names (postgres, redis, minio, mailpit) are the DNS hostnames inside infra_net. No IP addresses, no host port forwarding.


Testing and Verification

Bring the stack up:

terminal
docker compose up -d

Check that all services reach healthy status:

terminal
docker compose ps

Expected output:

NAME        IMAGE                    STATUS
traefik     traefik:v3.2             running
postgres    postgres:16-alpine       running (healthy)
redis       redis:7-alpine           running (healthy)
minio       minio/minio:latest       running (healthy)
mailpit     axllent/mailpit:latest   running (healthy)

If a service shows starting, wait 15 seconds and re-check. If it stays stuck, inspect the logs:

terminal
docker compose logs postgres --tail 20

Verify Traefik has registered the Mailpit route:

terminal
curl -s http://localhost:8080/api/http/routers | jq '.[].name'
# "mailpit@docker"

Open http://your-server:8005. The Mailpit UI should prompt for the credentials from MAILPIT_UI_AUTH.


Pitfalls

Bind mounts and Postgres superuser initialization

Using ./postgres_data:/var/lib/postgresql/data works until you change POSTGRES_USER without deleting the directory. Postgres writes the superuser into the data directory on first init and ignores env changes on subsequent starts. Use named volumes. To inspect the data directory, run docker volume inspect postgres_data to find the host path.

Binding Postgres or Redis to 0.0.0.0 on a public server

These services have no application-layer firewall. 0.0.0.0:5432:5432 makes your database reachable from the internet. Docker inserts iptables rules directly, bypassing UFW entirely. Use 127.0.0.1:PORT:PORT for any service that lacks its own auth layer.

Docker socket access

Mounting /var/run/docker.sock:ro gives Traefik read access to the full Docker API. Any process that compromises Traefik inherits that access. For environments where this is unacceptable, switch Traefik to its file provider and remove the socket mount.


Production Considerations

Move credentials out of .env before this stack handles real data. Docker Secrets, AWS SSM Parameter Store, and HashiCorp Vault all integrate with Compose. .env files in any repository, including private ones, are how credentials get leaked.

Add TLS to Traefik via a certificatesResolvers block in traefik.yml. Traefik handles Let's Encrypt issuance and renewal automatically. There's no reason to run plain HTTP once you have a domain pointed at the server.

Set MP_MAX_MESSAGES on Mailpit. The SQLite database grows without bound under sustained email traffic. A cap of 500 to 1000 messages is enough for most staging setups.

Enable pg_stat_statements before running any real query load:

docker-compose.yml
  postgres:
    command: postgres -c shared_preload_libraries=pg_stat_statements

Without it, identifying slow queries requires guesswork. With it, per-query execution stats are immediately available in pg_stat_statements.


Wrapping Up

You now have one shared infra stack that any app attaches to via infra_net. One docker compose up -d gives you a database, cache, object storage, and email testing with no cloud dependencies and no per-project duplication.

Next: lock down the network exposure before anything else hits the internet.


Comments