← Back to DevBytes

Docker Compose with Postgres and Redis: Best Practices and Common Pitfalls

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:

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:

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:

Application Service

The application container demonstrates the correct pattern for connecting to both data services:

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:

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:

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles