DEV Community

Cover image for Setting Up PostgreSQL with Docker Compose for Development and Production
Saiful Islam
Saiful Islam

Posted on

Setting Up PostgreSQL with Docker Compose for Development and Production

Working with PostgreSQL becomes much easier when you run it inside Docker. It gives you a clean environment, consistent versions, and a quick setup for both development and production. In this guide, we will walk step by step through a practical setup using Docker Compose.

This setup includes:

  • A custom PostgreSQL configuration
  • Automatic initialisation scripts
  • Separate Docker Compose files for development and production
  • A clear folder structure
  • Easy commands for starting and stopping the containers

Let us begin by reviewing the folder layout.


1. Folder Structure

Here is the structure used in this guide:

infra/
├─ docker/
│  ├─ docker-compose-dev.yml
│  └─ docker-compose.yml
├─ postgres/
│  ├─ init-scripts/
│  │  └─ init.sql
│  └─ postgres.conf
├─ package.json
Enter fullscreen mode Exit fullscreen mode

Each file has its own purpose:

  • **postgres.conf* → Custom PostgreSQL configuration
  • init.sql → Creates roles, permissions, and databases
  • docker-compose-dev.yml → Development compose file
  • docker-compose.yml → Production compose file
  • package.json scripts → Simple commands for starting and stopping services

2. Custom PostgreSQL Configuration

The file postgres.conf defines PostgreSQL tuning parameters.
Some of the key settings include:

  • Connection limits
  • Memory tuning
  • WAL optimisation
  • Logging options
  • Security settings

An example:

# Connection settings
listen_addresses = '*'
port = 5432
max_connections = 200

# Memory settings
shared_buffers = 512MB
effective_cache_size = 1GB
work_mem = 4MB
maintenance_work_mem = 128MB

# WAL settings
wal_buffers = 16MB
checkpoint_completion_target = 0.9
wal_compression = on
min_wal_size = 512MB
max_wal_size = 2GB

# Logging
log_destination = 'stderr'
logging_collector = off
log_min_messages = warning
log_min_error_statement = error
log_min_duration_statement = 1000

# Security
ssl = off
password_encryption = scram-sha-256

# Performance
random_page_cost = 1.1
effective_io_concurrency = 200
Enter fullscreen mode Exit fullscreen mode

Keeping this file external makes it easy to adjust performance for development and production without modifying container images.


3. Database Initialisation with init.sql

The init.sql script runs automatically the first time the container starts.
It performs tasks such as:

  • Creating roles (db_migrator, db_rw, db_ro)
  • Creating the test database
  • Setting permissions for each role
  • Configuring default privileges
  • Enforcing secure database access

An example:

-- ---------- 1. Create roles ----------
DO $$
BEGIN
    IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'db_migrator') THEN
        CREATE ROLE db_migrator LOGIN PASSWORD '12345678';
    END IF;

    IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'db_rw') THEN
        CREATE ROLE db_rw LOGIN PASSWORD '12345678';
    END IF;

    IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'db_ro') THEN
        CREATE ROLE db_ro LOGIN PASSWORD '12345678';
    END IF;
END
$$;

-- ---------- 2. Create databases ----------
CREATE DATABASE test;

-- ============================================================
-- Apply permissions for each database
-- ============================================================

------------------------------------------------------------
-- Setup for project_db
------------------------------------------------------------
\connect project_db

-- Remove unsafe default privileges
REVOKE ALL ON DATABASE project_db FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Prisma requires the db_migrator to own the schema
ALTER SCHEMA public OWNER TO db_migrator;

-- db_Migrator full control
GRANT USAGE, CREATE ON SCHEMA public TO db_migrator;

-- Read/Write application user
GRANT USAGE ON SCHEMA public TO db_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO db_rw;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO db_rw;

-- Readonly user
GRANT USAGE ON SCHEMA public TO db_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO db_ro;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO db_ro;

-- Default privileges for FUTURE objects created by db_migrator
ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO db_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT SELECT ON TABLES TO db_ro;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO db_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT USAGE, SELECT ON SEQUENCES TO db_ro;

-- Allow connections
GRANT CONNECT ON DATABASE project_db TO db_migrator, db_rw, db_ro;

-------------------------------
-- Setup for test database
-------------------------------
\connect test

REVOKE ALL ON DATABASE test FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

ALTER SCHEMA public OWNER TO db_migrator;
GRANT USAGE, CREATE ON SCHEMA public TO db_migrator;

GRANT USAGE ON SCHEMA public TO db_rw, db_ro;

GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO db_rw;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO db_rw;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO db_ro;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO db_ro;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO db_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT SELECT ON TABLES TO db_ro;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO db_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE db_migrator IN SCHEMA public
    GRANT USAGE, SELECT ON SEQUENCES TO db_ro;

GRANT CONNECT ON DATABASE test TO db_migrator, db_rw, db_ro;
Enter fullscreen mode Exit fullscreen mode

This ensures a predictable PostgreSQL environment and follows best practices for separating permissions.

Docker automatically executes any .sql file placed inside:

/docker-entrypoint-initdb.d
Enter fullscreen mode Exit fullscreen mode

This is why we mount that directory inside the container.


4. Development Compose File

The development file is simple and focuses on flexibility.
Key features include:

  • PGAdmin for database management
  • Exposed ports for local access
  • Mounted configuration and initialisation scripts
  • Health checks
  • Volumes for persistent storage

Example:

services:
  postgres:
    image: postgres:16.11-alpine3.22
    container_name: postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    env_file:
      - ../../.env
      - ../../.env.development.local
    volumes:
      - pg_volume:/var/lib/postgresql/data:rw
      - ../postgres/postgres.conf:/etc/postgresql/postgresql.conf:ro
      - ../postgres/init-scripts:/docker-entrypoint-initdb.d:ro
    command: [ "postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_statement=none", "-c", "log_min_duration_statement=1000", "-c", "log_connections=on", "-c", "log_disconnections=on" ]
    user: "70:70" # Postgres user
    healthcheck:
      test: [ "CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER:-postgres}" -d "$${POSTGRES_DB:-postgres}" -h localhost || exit 1' ]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s
    stop_grace_period: 120s
    networks:
      - project_network

  pgadmin:
    image: elestio/pgadmin:latest
    container_name: pgadmin
    restart: unless-stopped
    ports:
      - "5050:8080"
    env_file:
      - ../../.env
      - ../../.env.development.local
    volumes:
      - pgadmin_volume:/var/lib/pgadmin:rw
    depends_on:
      - postgres
    networks:
      - project_network

networks:
  project_network:
    driver: bridge

volumes:
  pg_volume:
    driver: local
  pgadmin_volume:
    driver: local
Enter fullscreen mode Exit fullscreen mode

Development environments usually need visibility, so logging and access remain more open.


5. Production Compose File

Production requires a stricter setup.
This file includes:

  • Resource limits
  • Security enhancements
  • Custom network settings
  • Logging configuration
  • No PGAdmin
  • No unnecessary services

Example:

services:
  postgres:
    image: postgres:16.11-alpine3.22
    container_name: postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    env_file:
      - ../../.env
      - ../../.env.production.local
    volumes:
      - pg_volume:/var/lib/postgresql/data:rw
      - ../postgres/postgres.conf:/etc/postgresql/postgresql.conf:ro
      - ../postgres/init-scripts:/docker-entrypoint-initdb.d:ro
    command: [ "postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_statement=none", "-c", "log_min_duration_statement=1000", "-c", "log_connections=on", "-c", "log_disconnections=on" ]
    user: "70:70" # Postgres user
    healthcheck:
      test: [ "CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER:-postgres}" -d "$${POSTGRES_DB:-postgres}" -h localhost || exit 1' ]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s
    stop_grace_period: 120s
    networks:
      - project_network
    security_opt:
      - no-new-privileges:true
    shm_size: "512mb"
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 512M
    labels:
      - "com.docker.compose.service=postgres"
      - "maintainer=your.dev.mail@mail.com"

networks:
  project_network:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: ma_bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
    labels:
      - "com.docker.compose.network=project_name"

volumes:
  pg_volume:
    driver: local
    labels:
      - "com.docker.compose.volume=pg_volume"
Enter fullscreen mode Exit fullscreen mode

Production setups focus on stability, performance, and security.


6. Running PostgreSQL with NPM Scripts

To simplify the workflow, the package.json includes convenient commands:

"scripts": {
  "docker-dev:up": "docker compose -f ./infra/docker/docker-compose-dev.yml -p project_name up -d",
  "docker-dev:down": "docker compose -f ./infra/docker/docker-compose-dev.yml -p project_name down",
  "docker:up": "docker compose -f ./infra/docker/docker-compose.yml -p project_name up -d",
  "docker:down": "docker compose -f ./infra/docker/docker-compose.yml -p project_name down"
}
Enter fullscreen mode Exit fullscreen mode

Now starting PostgreSQL is as simple as:

npm run docker-dev:up
Enter fullscreen mode Exit fullscreen mode

And stopping:

npm run docker-dev:down
Enter fullscreen mode Exit fullscreen mode

The same applies to production.


7. Conclusion

Using separate Docker Compose files for development and production provides a cleaner and safer workflow. Your development environment becomes flexible, while the production environment stays stable and optimised.

This approach also ensures:

  • Reproducible environments
  • Clear separation of roles and permissions
  • Easier onboarding for other developers
  • Stronger security controls
  • Scalable and maintainable infrastructure

You can now use PostgreSQL with confidence in any setup.

Top comments (0)