Introduction: Docker Compose with Postgres and Redis
Docker Compose is the de facto standard for orchestrating multi-container applications in development, testing, and increasingly in production environments. When you combine it with two of the most popular open-source data stores—Postgres for relational persistence and Redis for caching, session storage, and message brokering—you unlock a powerful local development stack that closely mirrors real-world infrastructure.
This tutorial walks you through configuring Docker Compose to run Postgres and Redis alongside your application service. We'll cover the fundamentals, dive deep into best practices that prevent painful surprises down the road, and expose the most common pitfalls developers encounter—along with concrete fixes for each one. By the end, you'll have a production-ready compose file and a solid mental model for reasoning about multi-service data architectures in containers.
Why This Stack Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Postgres and Redis represent two complementary halves of modern application data management:
- Postgres handles structured, relational data with ACID guarantees—user records, orders, financial transactions, and anything requiring integrity and complex querying.
- Redis excels at high-throughput, low-latency operations—caching expensive database queries, storing ephemeral session tokens, rate limiting counters, pub/sub messaging, and background job queues.
Running both locally via Docker Compose means you avoid installing these services directly on your machine, sidestep version conflicts across projects, and ensure every developer on your team works against identical infrastructure. The compose file becomes a single source of truth for your entire data layer.
Getting Started: Project Structure
Before writing configuration, establish a clean project layout. Here's a recommended structure that separates concerns while keeping everything discoverable:
project-root/
├── docker-compose.yml
├── .env # environment variables shared across services
├── postgres/
│ ├── init.sql # optional: seed data or schema bootstrap scripts
│ └── data/ # gitignored mount point for persisted data
├── redis/
│ ├── redis.conf # custom Redis configuration
│ └── data/ # gitignored mount point for RDB/AOF files
└── app/
├── Dockerfile
└── ...application code...
The postgres/init.sql file is automatically executed by the official Postgres image on first startup when mounted to /docker-entrypoint-initdb.d/. This is perfect for seeding test data or running initial schema migrations in development. The .env file keeps secrets and per-environment values out of the compose file itself.
Writing the docker-compose.yml File
The Complete Configuration
Below is a robust, well-annotated compose file that brings up Postgres 16, Redis 7, and a hypothetical Node.js application. We'll then dissect each service in detail.
# docker-compose.yml
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: app-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-appuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme_in_dev_only}
POSTGRES_DB: ${POSTGRES_DB:-appdb}
POSTGRES_INITDB_ARGS: "--auth=scram-sha-256"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-appuser} -d ${POSTGRES_DB:-appdb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
cpus: "2"
memory: 512M
redis:
image: redis:7-alpine
container_name: app-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD:-redis_dev_password}
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD:-redis_dev_password}", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
cpus: "1"
memory: 256M
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: app-backend
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV:-development}
DATABASE_URL: postgresql://${POSTGRES_USER:-appuser}:${POSTGRES_PASSWORD:-changeme_in_dev_only}@postgres:5432/${POSTGRES_DB:-appdb}
REDIS_URL: redis://default:${REDIS_PASSWORD:-redis_dev_password}@redis:6379
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1"
memory: 512M
volumes:
postgres_data:
name: app_postgres_data
redis_data:
name: app_redis_data
Breaking Down Each Service
Postgres Service
The postgres:16-alpine image is pinned to a major version and uses the Alpine variant to minimize image size (around 100MB vs 400MB for the Debian-based image). Key configuration points:
- Authentication:
POSTGRES_INITDB_ARGSenforces SCRAM-SHA-256 password hashing rather than the weaker default MD5 method. This matters even in development because it mirrors what you'll use in production. - Volume mount:
postgres_data:/var/lib/postgresql/datais a named volume (not a bind mount) managed by Docker. Named volumes offer better performance on macOS and Windows compared to bind mounts, and they survive container removal. - Init scripts: The
./postgres/init.sqlfile is mounted read-only into the initdb directory. Files in this directory run alphabetically, so the01-prefix ensures ordering when you add more scripts later. - Healthcheck:
pg_isreadyis the canonical readiness probe—it checks that Postgres is accepting connections without requiring a full query. Avoid usingpsqlorpg_ctlfor this; they have side effects or require superuser access.
Redis Service
The Redis configuration uses the command override to pass critical arguments directly, while also mounting a custom config file for anything more complex:
- Password protection: Even in local development, requiring authentication prevents accidental cross-project connections and mirrors production security posture. The
--no-auth-warningflag in the healthcheck suppresses the "no password set" warning when authenticating. - AOF persistence:
--appendonly yesenables the Append-Only File, which logs every write operation. Combined with the default RDB snapshots, this gives you durable persistence. For pure caching use cases where data loss is acceptable, you can omit this. - Memory management:
--maxmemorycaps Redis at 256MB, andallkeys-lruevicts the least recently used keys when the limit is reached. Without this, Redis will consume all available container memory and potentially crash. - Volume:
redis_data:/datastores both the RDB dump file and the AOF log, ensuring data survives container recreation.
Application Service
The application container demonstrates the correct pattern for connecting to both data services:
- Internal networking: Connection strings use
postgresandredisas hostnames—these are the service names defined in the compose file. Docker's internal DNS resolves them automatically within the default network. - Depends_on with conditions: The
depends_onblock usescondition: service_healthy(available since Compose v3.9 / Docker Compose 1.27+). This prevents the app from starting until both Postgres and Redis pass their health checks. Plaindepends_onwithout conditions only waits for the container process to start—not for the service to be actually ready. - Environment variables: The
${VARIABLE:-default}syntax provides fallback values, making the file usable without a.envfile in development while still allowing per-environment overrides.
Best Practices for Postgres in Docker Compose
1. Always Pin Your Image Version
Never use postgres:latest or bare postgres tags in any environment. A major version bump (e.g., 15 to 16) changes the on-disk data format, and automatic upgrades can corrupt your data volume. Pin to at least the major version: postgres:16-alpine. For production, pin to a specific patch version: postgres:16.3-alpine.
2. Use Named Volumes for Persistent Data
Named volumes (the volumes: section at the bottom of the compose file) are managed by Docker and offer several advantages over bind mounts for database storage:
- They work consistently across operating systems without path translation issues
- Docker's volume drivers support backup, migration, and encryption plugins
- They don't accidentally inherit host file permissions
Always give volumes explicit names (like app_postgres_data) rather than relying on auto-generated names. This makes backup scripts and cross-project volume sharing predictable.
3. Implement Proper Health Checks
A health check is not optional for database services—it's essential. Without it, your application will attempt connections before Postgres finishes its recovery or initial setup, leading to frustrating "connection refused" errors during cold starts. The pg_isready command is purpose-built for this:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-appuser} -d ${POSTGRES_DB:-appdb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
The start_period parameter (supported in Docker Compose v3.4+) gives Postgres a grace period before marking it unhealthy. On first run with a large init script, Postgres might need 30-60 seconds. Adjust this based on your seed data size.
4. Leverage Environment Variables Correctly
The Postgres image supports a rich set of environment variables for initialization. Beyond the basics, consider these useful ones:
PGDATA: Override the data directory location (useful when you need multiple data directories on different mount points)POSTGRES_HOST_AUTH_METHOD: Set toscram-sha-256for production-grade password hashing (the default ismd5in many images)POSTGRES_INITDB_WALDIR: Separate WAL (write-ahead log) onto a different volume for performance tuning in heavy-write scenarios
Keep sensitive values like passwords in the .env file, never hardcoded in docker-compose.yml. Add .env to .gitignore and distribute a .env.example template instead.
5. Configure Resource Limits
Unconstrained containers can consume all available host resources, starving other services or crashing the Docker daemon. The deploy.resources section enforces limits:
deploy:
resources:
limits:
cpus: "2"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M
Reservations guarantee a minimum while limits cap the maximum. For Postgres, set memory limits generously—the shared_buffers and work_mem settings consume from this pool. A 512MB limit is reasonable for development; production may need several gigabytes.
Best Practices for Redis in Docker Compose
1. Choose the Right Redis Variant
Redis offers several official images. For most development and production use cases, redis:7-alpine hits the sweet spot: small footprint, full functionality, and the latest stable major version. If you need Redis Stack features (JSON, search, time-series, probabilistic data structures), use redis/redis-stack:latest instead, which bundles these modules.
2. Enable Persistence When Needed
Redis operates in memory, but two persistence mechanisms prevent data loss across restarts:
- RDB (snapshotting): Enabled by default. Redis dumps the entire dataset to
/data/dump.rdbat configurable intervals. - AOF (append-only file): Logs every write command. More durable but larger file size. Enable with
--appendonly yes.
For session stores and caching where data is ephemeral, you can disable both and treat Redis as a pure cache. For job queues or feature flag stores, enable AOF to survive container recreation without data loss. Here's how to configure hybrid persistence via a custom config file:
# redis/redis.conf
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
Mount this file and reference it in the container command:
command: redis-server /usr/local/etc/redis/redis.conf --requirepass ${REDIS_PASSWORD}
3. Set Proper Memory Policies
The maxmemory-policy setting determines what happens when Redis hits its memory limit. The choice depends entirely on your use case:
- allkeys-lru: Best for pure caching. Evicts any key based on LRU algorithm. Ideal when all data is expendable.
- volatile-lru: Evicts only keys with an expiration TTL set. Use when you mix persistent and ephemeral data.
- noeviction: Rejects writes when full. Appropriate for job queues where losing data is unacceptable—but you must monitor memory closely.
Never run Redis in production without a maxmemory limit. An unbounded Redis will eventually consume all available RAM and trigger an OOM kill from the kernel, losing all in-flight data.
4. Secure Redis with a Password
Redis has no authentication by default—anyone who can reach port 6379 can run arbitrary commands, including FLUSHALL and CONFIG SET. Always set a password, even locally:
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_password}
For additional security, consider renaming dangerous commands via the config file:
# In redis.conf
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG "CONFIG_84729b5c"
This prevents accidental data destruction while still allowing the commands under a secret name if needed.
Common Pitfalls and How to Avoid Them
Pitfall 1: Race Conditions on Startup
The problem: Your application starts before Postgres finishes initializing, causing connection failures that either crash the app or trigger confusing retry logic.
The fix: Use depends_on with condition: service_healthy (requires Compose v3.9+ and Docker Compose 1.27+):
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
If you're on an older Docker Compose version, implement retry logic with exponential backoff in your application's startup code. But the modern solution is far cleaner—upgrade Docker Compose if you haven't already.
Pitfall 2: Data Loss Due to Anonymous Volumes
The problem: You omit the volumes: section or use inline volume syntax without a named volume. Running docker compose down (without -v) preserves named volumes but docker compose rm or certain orchestration tools may remove anonymous ones. Developers lose their local databases unexpectedly.
The fix: Always declare named volumes explicitly at the bottom of your compose file:
volumes:
postgres_data:
name: app_postgres_data
redis_data:
name: app_redis_data
Use docker compose down --volumes only when you intentionally want to destroy data. For daily development, use docker compose down (without --volumes) to stop and remove containers while preserving volumes.
Pitfall 3: Hardcoded Credentials in docker-compose.yml
The problem: Passwords and API keys embedded directly in the compose file end up in version control, leaked in dotfiles repositories, or shared accidentally in screenshots and logs.
The fix: Use variable substitution with a .env file:
# .env (gitignored)
POSTGRES_USER=appuser
POSTGRES_PASSWORD=secure_password_here
REDIS_PASSWORD=another_secure_password
The compose file references these with ${POSTGRES_PASSWORD}. Commit a .env.example file with placeholder values so new developers know what variables to set. Never commit the actual .env file.
Pitfall 4: Not Setting max_connections in Postgres
The problem: Postgres defaults to 100 max connections. In containerized environments where multiple replicas of your app connect to a single Postgres instance, you can easily exhaust this limit, resulting in "too many connections" errors.
The fix: Set max_connections explicitly via a custom config file or command-line flag:
# In a mounted postgresql.conf or passed as command argument
max_connections = 200
Calculate your needed connections: (app instances × connection pool size) + background workers + maintenance overhead. For a typical microservice with a pool of 20 connections and 3 instances, set at least 80.
Pitfall 5: Ignoring Redis Memory Configuration
The problem: Redis runs without maxmemory limits, gradually consuming all container memory until the OOM killer terminates it. This is especially dangerous in shared development environments where one developer's Redis can starve others.
The fix: Always set both maxmemory and an eviction policy:
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--requirepass ${REDIS_PASSWORD}
Monitor Redis memory usage via redis-cli INFO memory and adjust limits based on observed working set sizes. For development, 256MB is usually generous; adjust upward for production based on your cache hit rate requirements.
Pitfall 6: Network Misconfigurations
The problem: Connecting to localhost or 127.0.0.1 from within a container tries to reach the container itself, not the Postgres or Redis service. Each container has its own loopback interface.
The fix: Always use the service name as the hostname. Docker Compose's internal DNS resolves postgres and redis to the correct container IPs within the default network:
# Correct - uses Docker's internal DNS
DATABASE_URL: postgresql://user:password@postgres:5432/dbname
REDIS_URL: redis://:password@redis:6379
# Wrong - localhost refers to the app container itself
DATABASE_URL: postgresql://user:password@localhost:5432/dbname
If you need to access these services from your host machine (for GUI tools like pgAdmin or Redis Insight), use localhost:5432 and localhost:6379—the port mappings handle that direction.
Pitfall 7: Using 'latest' Tags in Production
The problem: The latest tag pulls whatever the current stable version is at build time. Different developers or CI pipeline runs may get different versions, leading to inconsistent behavior and impossible-to-reproduce bugs. Worse, a major version change can silently corrupt data volumes.
The fix: Pin to a specific version tag, and ideally a digest hash in production:
# Good - pinned to major version
image: postgres:16-alpine
# Better - pinned to specific patch
image: postgres:16.3-alpine
# Best - immutable digest (production)
image: postgres:16.3-alpine@sha256:abc123...
Implement a dependency update process (Renovate, Dependabot) that bumps these pinned versions with proper testing, rather than floating on latest.
Putting It All Together: A Production-Ready Example
Here's a complete, production-oriented compose file that incorporates all the best practices and avoids every pitfall discussed above. This configuration is suitable for staging environments and, with appropriate secrets management, can serve as the foundation for production deployments:
# docker-compose.yml (production-ready template)
version: "3.9"
services:
postgres:
image: postgres:16.3-alpine
container_name: app-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_INITDB_ARGS: "--auth=scram-sha-256 --wal-segsize=16"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
cpus: "4"
memory: 2G
reservations:
cpus: "1"
memory: 512M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7.2-alpine
container_name: app-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--maxmemory ${REDIS_MAXMEMORY:-512mb}
--maxmemory-policy ${REDIS_EVICTION_POLICY:-allkeys-lru}
--save 900 1
--save 300 10
--save 60 10000
volumes:
- redis_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
cpus: "2"
memory: 512M
reservations:
cpus: "0.5"
memory: 128M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
app:
build:
context: ./app
dockerfile: Dockerfile
args:
- NODE_ENV=${NODE_ENV:-production}
image: app-backend:${APP_VERSION:-latest}
container_name: app-backend
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://default:${REDIS_PASSWORD}@redis:6379
REDIS_MAX_RETRIES: "10"
REDIS_RETRY_INTERVAL_MS: "1000"
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "2"
memory: 1G
reservations:
cpus: "0.5"
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
volumes:
postgres_data:
name: app_postgres_data_production
redis_data:
name: app_redis_data_production
networks:
default:
name: app_backend_network
driver: bridge
Notice the additions beyond the basic development setup: log rotation prevents disk exhaustion from container logs, resource reservations guarantee baseline performance, and the custom network name makes it discoverable by other compose files or external monitoring tools. The Redis configuration now uses environment variables for memory and eviction policy, allowing per-environment tuning without editing the compose file itself.
Conclusion
Docker Compose with Postgres and Redis gives you a powerful, reproducible data layer that works identically across development, CI, and staging environments. The difference between a fragile setup and a robust one lies in the details: pinned image versions prevent silent breakage, named volumes safeguard your data, health checks eliminate startup race conditions, and explicit resource limits keep services from stepping on each other.
Start with the development configuration shown early in this tutorial, then progressively adopt the production-ready patterns as your project matures. Pay special attention to the pitfalls section—every one of those issues has bitten experienced teams at some point. By internalizing these best practices now, you'll sidestep hours of debugging and build containerized infrastructure you can genuinely rely on, whether you're running locally on a laptop or deploying to a cloud orchestrator.