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.
networks:
infra_net:
driver: bridge
name: infra_net
volumes:
postgres_data:
redis_data:
minio_data:
traefik_logs:
mailpit_data:
2. Configure Traefik¶
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¶
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¶
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¶
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¶
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¶
# 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¶
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:
Check that all services reach healthy status:
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:
Verify Traefik has registered the Mailpit route:
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:
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.
- Securing a Public-Facing Dev Stack: UFW, Localhost Bindings, and SSH Tunnels
- Full source: github.com/joseph-fidelis