Building Infrastructure with Docker — Part 1: PostgreSQL
🗄️ Building Infrastructure with Docker — Part 1
PostgreSQL: A Reusable, Independent Database Module
Welcome to Part 1 of the Building Infrastructure with Docker series. In Part-0, we set the foundation: one mono-repo, multiple fully independent modules, each engineered with deterministic behaviour, pinned container images, self-contained docs, health checks, and Makefile-driven execution.
We now begin the first real module in the series — PostgreSQL.
This module is intentionally simple but extremely important: every other module in this series that interacts with a relational database or requires CDC (Change Data Capture using Debezium later) will rely on the principles we establish here.
Our goal is not to “run PostgreSQL in Docker.” Our goal is to engineer a reusable infrastructure component you can plug into any project.
🎯 What We Are Building in Part 1
This module provides a standalone PostgreSQL instance inside the repository under:
infra-docker-series/modules/postgres/
Once built, this module:
- Runs independently using
make up - Provides health checks using
pg_isready - Stores data consistently under
docker-volume/data/ - Uses pinned Docker image versions (no
latest) - Exposes a simple smoke test (
SELECT 1) - Can be reused across any other project you build
If you learn the pattern here, every future module will feel natural.
1. 🧱 Folder Structure — Postgres Module
Inside the mono-repo, the PostgreSQL module lives here:
modules/
postgres/
docs/
README.md
requirements.md
design-intent.md
diagrams/
infra/
docker-compose.yml
docker-volume/
data/
scripts/
test_health.sh
.env.example
Makefile
Jenkinsfile
This layout is the canonical scaffold for the entire series. Kafka, Mosquitto, Keycloak, Prometheus, and all future parts will follow this identical pattern.
🧩 The Architecture (Simple but as-is)
- Data is bind-mounted locally (not named volumes)
- A dedicated Docker network
postgres_netisolates traffic - Everything is driven by environment variables loaded from
.env
This keeps it production-minded while still very easy to work with.
2. PostgreSQL module files
All paths below are relative to:
infra-docker-series/modules/postgres/
2.1 modules/postgres/.env.example
# Postgres container settings
POSTGRES_CONTAINER_NAME=infra_postgres
POSTGRES_DB=app_db
POSTGRES_USER=app_user
POSTGRES_PASSWORD=change_me_locally
POSTGRES_PORT=5432
# Image version pin
POSTGRES_VERSION=15
You will copy this to
.envmanually and adjustPOSTGRES_PASSWORDetc..envshould be gitignored (already covered by root.gitignore).
2.2 modules/postgres/infra/docker-compose.yml
version: "3.9"
services:
postgres:
image: postgres:${POSTGRES_VERSION}
container_name: ${POSTGRES_CONTAINER_NAME}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- ../docker-volume/data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- postgres_net
networks:
postgres_net:
name: postgres_net
driver: bridge
Notes:
- Volume path
../docker-volume/datais relative toinfra/, mapping todocker-volume/dataat module root. - Image pinned via
POSTGRES_VERSIONenv, defaulting to15.
2.3 modules/postgres/Makefile (module-level)
# PostgreSQL module Makefile
COMPOSE_FILE := infra/docker-compose.yml
DOCKER_COMPOSE := docker compose -f $(COMPOSE_FILE)
ENV_FILE ?= .env
.PHONY: help init up down logs test clean psql
help:
@echo "PostgreSQL module"
@echo "Targets:"
@echo " init - prepare env and folders"
@echo " up - start postgres container"
@echo " down - stop postgres container"
@echo " logs - tail container logs"
@echo " test - run health/smoke test"
@echo " clean - stop and remove data after confirmation"
@echo " psql - open psql shell into the database"
init:
@if [ ! -f "$(ENV_FILE)" ]; then \
echo "Creating $(ENV_FILE) from .env.example"; \
cp .env.example $(ENV_FILE); \
else \
echo "$(ENV_FILE) already exists, skipping copy."; \
fi
@mkdir -p docker-volume/data
@echo "Pulling images..."
@$(DOCKER_COMPOSE) --env-file $(ENV_FILE) pull
up:
@$(DOCKER_COMPOSE) --env-file $(ENV_FILE) up -d
down:
@$(DOCKER_COMPOSE) --env-file $(ENV_FILE) down
logs:
@$(DOCKER_COMPOSE) --env-file $(ENV_FILE) logs -f
test:
@./scripts/test_health.sh $(ENV_FILE)
clean:
@read -p "This will stop the container and DELETE docker-volume/data. Continue? (y/N) " ans; \
if [ "$$ans" = "y" ] || [ "$$ans" = "Y" ]; then \
$(DOCKER_COMPOSE) --env-file $(ENV_FILE) down; \
rm -rf docker-volume/data; \
echo "Data directory removed."; \
else \
echo "Aborted."; \
fi
psql:
@./scripts/test_health.sh $(ENV_FILE) --psql
2.4 modules/postgres/scripts/test_health.sh
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env}"
MODE="${2:-health}"
if [ ! -f "$ENV_FILE" ]; then
echo "Env file '$ENV_FILE' not found. Run 'make init' first."
exit 1
fi
# shellcheck disable=SC2046
export $(grep -v '^\s*#' "$ENV_FILE" | xargs)
CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-infra_postgres}"
echo "Using container: ${CONTAINER_NAME}"
echo "Checking health status..."
HEALTH_STATUS=$(docker inspect \
--format='{{.State.Health.Status}}' \
"${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
if [ "$HEALTH_STATUS" != "healthy" ]; then
echo "Container health status: ${HEALTH_STATUS}"
echo "Hint: check logs with 'make logs' or 'docker logs ${CONTAINER_NAME}'"
exit 1
fi
echo "Container is healthy."
if [ "$MODE" = "--psql" ]; then
echo "Opening psql shell..."
docker exec -it "${CONTAINER_NAME}" \
psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}"
exit 0
fi
echo "Running smoke test: SELECT 1;"
docker exec -i "${CONTAINER_NAME}" \
psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
-c "SELECT 1;" >/dev/null
echo "Smoke test passed."
Make sure to
chmod +x modules/postgres/scripts/test_health.sh.
2.5 modules/postgres/docs/README.md
# PostgreSQL Infrastructure Module
This module provides a reusable PostgreSQL instance running in Docker with:
- Pinned PostgreSQL version (configured via `.env`).
- Local bind-mounted storage under `docker-volume/data`.
- Healthcheck configuration.
- Makefile-driven lifecycle (`init`, `up`, `test`, `down`, `clean`).
- Simple smoke test using `psql` (`SELECT 1`).
## Quick Start
```bash
# From repo root
cd modules/postgres
# Prepare env and pull images
make init
# Start PostgreSQL
make up
# Run health and smoke test
make test
# Optional: open psql shell into the DB
make psql
# Stop PostgreSQL
make down
Data is stored under modules/postgres/docker-volume/data and persists across restarts.
Use make clean to remove it after confirmation.
2.6 modules/postgres/docs/requirements.md
# PostgreSQL Module – Requirements
## Business / Functional Intent
- Provide a standalone PostgreSQL instance that can be:
- Started and stopped via Makefile and Docker Compose.
- Reused across multiple application projects.
- Used as the source database for future modules (e.g., Debezium CDC).
- Keep the module self-contained so it can be cloned and used without the rest of the series.
## Non-Functional Requirements
- Deterministic setup:
- Explicit PostgreSQL image version (no `latest`).
- Single, documented port with override capability via `.env`.
- Persistence:
- Data stored under `docker-volume/data` inside the module.
- Safe cleanup via `make clean` only after explicit confirmation.
- Observability:
- Docker healthcheck using `pg_isready`.
- Smoke test using `psql` (`SELECT 1`).
- Security:
- Credentials defined in `.env` (not committed).
- `.env.example` provided for reference.
- Portability:
- No external dependencies beyond Docker and Docker Compose.
- No hard-coded host paths; everything relative to the module root.
2.7 modules/postgres/docs/design-intent.md
# PostgreSQL Module – Design Intent
## Overview
This module provides a single PostgreSQL instance intended as:
- A reusable DB for local development.
- A base for CDC experiments (e.g., with Debezium in another module).
- A template for how other infrastructure modules in this series are structured.
## Architecture
```mermaid
flowchart LR
dev[Developer] --> docker[Docker Engine]
docker --> compose[Docker Compose]
compose --> pg[PostgreSQL Container]
pg --> vol[docker-volume data]
docker-compose.ymldefines a singlepostgresservice.- Data directory is bind-mounted from
docker-volume/data. - A dedicated Docker bridge network
postgres_netisolates traffic.
2.8 modules/postgres/Jenkinsfile (skeleton)
pipeline {
agent any
environment {
MODULE_DIR = "modules/postgres"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Init') {
steps {
dir(MODULE_DIR) {
sh 'make init'
}
}
}
stage('Up') {
steps {
dir(MODULE_DIR) {
sh 'make up'
}
}
}
stage('Test') {
steps {
dir(MODULE_DIR) {
sh 'make test'
}
}
}
stage('Down') {
steps {
dir(MODULE_DIR) {
sh 'make down'
}
}
}
}
post {
always {
script {
try {
dir(MODULE_DIR) {
sh 'make down || true'
}
} catch (err) {
echo "Cleanup failed: ${err}"
}
}
}
}
}
This is just a reference Jenkins skeleton file, not yet complete. leaving Jenkins out of scope for this series.
3.⚙️ Core Configuration
The PostgreSQL behaviour is driven by .env.example, which you copy into .env:
POSTGRES_DB=app_db
POSTGRES_USER=app_user
POSTGRES_PASSWORD=change_me_locally
POSTGRES_PORT=5432
POSTGRES_VERSION=15
Why this matters:
- You can change the port without editing the compose file.
- You can rotate credentials without committing secrets.
- You can upgrade PostgreSQL by adjusting a single version variable.
This reflects real-world configuration hygiene.
4. 🐘 docker-compose.yml — Pinned, Predictable, Safe
The module’s docker-compose.yml pins PostgreSQL to a specific version:
image: postgres:${POSTGRES_VERSION}
and sets a solid health check:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
The container is only considered ready when PostgreSQL is truly accepting connections.
This tiny decision prevents unpredictable behavior in downstream systems—something every engineer learns the hard way at least once.
5. 🔧 Makefile-Driven Lifecycle
Every module in this series uses the same Makefile contract:
| Command | What It Does |
|---|---|
make init |
Creates .env, prepares folders, pulls images |
make up |
Starts the PostgreSQL container |
make test |
Health check + smoke test (SELECT 1) |
make down |
Stops the service |
make clean |
Stops service + removes data (with confirmation) |
make psql |
Opens an interactive shell inside PostgreSQL |
You can run it inside the module:
cd modules/postgres
make init
make up
make test
make psql
make down
Or from the root of the repo:
make init MODULE=postgres
make up MODULE=postgres
make test MODULE=postgres
This uniform experience across all modules is the core training aspect of this series.
6. 🩺 Smoke Test: Trust, but Verify
After make up, you run:
make test
This executes test_health.sh which:
- Checks Docker health status → must be
healthy - Runs:
SELECT 1;
via psql inside the container.
If both succeed, your PostgreSQL module is considered operational.
If either fails, logs are suggested:
make logs
This one minute test ensures your infra module isn’t just “running”… …it’s actually working.
7. 💾 Persistence Strategy
Your data lives here:
modules/postgres/docker-volume/data/
This folder:
- is bind-mounted (not named volume)
- is owned by this module only
- is always safe to delete manually if needed
- is ignored from Git by default
This approach:
- Lets you snapshot/copy/backup easily
- Ensures no global Docker volumes pollute your system
- Keeps module state self-contained
Exactly how enterprise infra-as-code teams work.
8. 🧪 Real-World Deployment Notes (Quick)
Even though this module runs locally:
- The same Docker Compose file becomes your base for Kubernetes StatefulSets.
.envvalues map to Kubernetes Secrets and ConfigMaps.docker-volume/datamaps to PersistentVolumeClaims.- Healthchecks translate to liveness/readiness probes.
This is deliberate. This series trains you for real production patterns, not just local demos.
9. ⚠️ Common Pitfalls (and How We Avoid Them)
| Pitfall | How This Module Prevents It |
|---|---|
Using latest image |
Version pinned in .env.example |
| Global Docker volumes | Local bind-mount inside module only |
| Weak health checks | pg_isready validates actual DB readiness |
| Secrets committed to git | .env ignored, .env.example only |
| Data loss on tear-down | make clean requires explicit confirmation |
| Non-repeatable setup | Deterministic Makefile + explicit design files |
Consistency in environment setup prevents 90% of infra headaches.
10. 📘 Summary of Part 1
By completing this module, you now have:
- A fully reusable Postgres infra unit
- Pinned version
- Deterministic runtime
- Isolated storage
- Health checks
- Makefile-driven execution
- Aligned folder structure for all upcoming modules
This is your first building block in the larger infrastructure library we are constructing.
You can take this modules/postgres folder and drop it into any other project you build — it will behave exactly the same.
Coming Up Next — Part 2: Kafka + Zookeeper
In the next part, we’ll build:
- A pinned Kafka 3.4.x + Zookeeper 3.6.x module
- With health checks
- With a topic creation smoke test
- With producer and consumer validation
- And the same canonical scaffold structure
Kafka is the backbone for many downstream modules (Debezium, telemetry pipelines, distributed tracing), so it’s the natural next step.
GitHub Repository Link
🔗 Project Repo: https://github.com/KathiravanMuthaiah/infrastructureWithDocker
Building Infrastructure with Docker Series: post links
🔗 Building Infrastructure with Docker — Part0:
🔗 Building Infrastructure with Docker — Part2:
“Technically authored by me, accelerated with insights from ChatGPT by OpenAI.” Refer: Leverage ChatGPT
Happy Learning