PostgreSQL is the backbone of modern self-hosted infrastructure.
Immich stores your photo metadata in it. Nextcloud maps your
files through its database. Gitea tracks issues and PRs. Authentik
authenticates against it. Vaultwarden keeps your vault items in it.
Almost every serious self-hosted service uses PostgreSQL as its
primary datastore.
Running one central PostgreSQL instance with Docker Compose is the
sensible pattern for homelabs running multiple services. A single
well-tuned container replaces a dozen separate database instances,
reduces resource overhead, simplifies backup automation, and gives
every app a reliable, consistent backend.
This guide walks through deploying PostgreSQL 16 with Docker Compose
in a homelab — persistent storage, health checking, performance
tuning, the pgvector extension for AI workloads, automated backup
with systemd timers, and optional streaming replication. Every config
file and command has been tested on Docker Engine 27+ and Compose 2.30+
running on Debian 12.
Prerequisites for PostgreSQL Docker Deployment#
Before starting, verify your host meets these requirements:
- Docker Engine 24+ and Docker Compose v2 installed
- Debian 12 / Ubuntu 24.04 or equivalent Linux distribution
- At least 2 GB RAM, 2 vCPUs, and 20 GB SSD for database storage
- Basic familiarity with environment variables and
pg_dump
Create a dedicated directory for the PostgreSQL deployment:
1
2
|
mkdir -p /opt/postgres/{data,backups,init}
cd /opt/postgres
|
Production-Grade Docker Compose Configuration#
The compose file below deploys a hardened PostgreSQL 16 Alpine
container with named volumes, an internal network so apps can
connect without exposing ports to the host, and a health check
that verifies database readiness before dependent services start.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
# /opt/postgres/docker-compose.yml
services:
postgres:
image: postgres:16-alpine
container_name: homelab-postgres
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d:ro
- ./backups:/backups
environment:
- POSTGRES_USER=${POSTGRES_USER:-homelab}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:-homelab}
- TZ=${TZ:-America/Santo_Domingo}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-homelab}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- db-net
ports:
- "127.0.0.1:5432:5432"
volumes:
pgdata:
networks:
db-net:
name: db-net
internal: true
|
Create a .env file with your credentials:
1
2
3
4
5
|
# /opt/postgres/.env — do not commit this file
POSTGRES_USER=homelab
POSTGRES_PASSWORD=replace-with-generated-password
POSTGRES_DB=homelab
TZ=America/Santo_Domingo
|
Generate a strong random password and update the .env file:
1
2
|
openssl rand -base64 32
# Copy the output and paste it as the POSTGRES_PASSWORD value
|
Set strict permissions on the .env file:
1
|
chmod 600 /opt/postgres/.env
|
The localhost-only port mapping (127.0.0.1:5432:5432) lets host
tools like psql or pg_dump reach the database without joining
the Docker network. Other containers connect via the internal
db-net network instead.
Initialization Scripts and pgvector Extension#
Place SQL scripts in the init/ directory. They run alphabetically
the first time PostgreSQL initializes the data directory. Add the
pgvector extension for AI and embedding workloads here:
1
2
3
4
|
-- /opt/postgres/init/01-extensions.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS uuid-ossp;
|
pgvector enables PostgreSQL to store and query vector embeddings
from Ollama, OpenAI-compatible APIs, or any AI pipeline. Apps like
Immich use pgvector for CLIP-based smart search and facial
recognition embeddings.
Start the container and verify the extensions loaded:
1
2
|
docker compose up -d
docker exec homelab-postgres psql -U homelab -c "SELECT * FROM pg_extension;"
|
Performance Tuning for Docker PostgreSQL#
The default postgresql.conf inside the Alpine image uses
conservative settings appropriate for low-memory environments.
For a homelab with 8-16 GB of RAM, these settings leave
performance on the table.
Mount a custom configuration file to override the defaults:
1
2
3
4
5
6
|
# Add to the postgres service volumes section in docker-compose.yml
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
command:
- "postgres"
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
|
Create postgresql.conf:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
# /opt/postgres/postgresql.conf — PostgreSQL 16 tuning for homelab
# Adjust these values based on your available system RAM
# Memory — these are for an 8 GB host
shared_buffers = '2GB' # 25% of total RAM
effective_cache_size = '6GB' # 75% of total RAM
work_mem = '32MB' # Per-query sort/hash memory
maintenance_work_mem = '256MB' # VACUUM, CREATE INDEX, etc.
wal_buffers = '16MB'
# WAL
wal_level = logical # Enables logical replication
max_wal_size = '2GB'
min_wal_size = '512MB'
# Connections
max_connections = '100'
superuser_reserved_connections = '5'
# Planner
random_page_cost = '1.1' # SSD-optimized (default 4.0 for HDD)
effective_io_concurrency = '200' # SSD with high IOPS
# Autovacuum — critical for Docker where connections fluctuate
autovacuum = on
autovacuum_max_workers = '3'
autovacuum_naptime = '1min'
autovacuum_vacuum_threshold = '50'
autovacuum_vacuum_scale_factor = '0.01'
autovacuum_vacuum_cost_limit = '1000'
# Docker-specific TCP keepalives — prevents dropped connections
tcp_keepalives_idle = '60'
tcp_keepalives_interval = '10'
tcp_keepalives_count = '6'
|
Restart PostgreSQL to apply the custom config:
1
|
docker compose up -d --force-recreate postgres
|
Verify the settings took effect:
1
2
|
docker exec homelab-postgres psql -U homelab -c "SHOW shared_buffers;"
docker exec homelab-postgres psql -U homelab -c "SHOW effective_cache_size;"
|
Tuning by Host Memory Profile#
| Host RAM |
shared_buffers |
effective_cache_size |
maintenance_work_mem |
| 8 GB |
2 GB |
6 GB |
256 MB |
| 16 GB |
4 GB |
12 GB |
512 MB |
| 32 GB |
8 GB |
24 GB |
1 GB |
The random_page_cost = 1.1 setting is critical for SSD-backed
homelabs. The default value of 4.0 is tuned for spinning HDDs and
causes PostgreSQL to over-value sequential scans over index scans.
With NVMe or SATA SSDs, setting this to 1.1 more accurately reflects
the actual cost of random I/O.
Authentication and Security Hardening#
PostgreSQL Docker containers need careful security consideration
since they host data for multiple applications.
Password Encryption#
The postgres:16-alpine image defaults to scram-sha-256
password encryption. Verify this with:
1
|
docker exec homelab-postgres psql -U homelab -c "SHOW password_encryption;"
|
Never Expose Ports Publicly#
The compose file binds to 127.0.0.1:5432 only. Other Docker
containers connect via the internal db-net network, which has no
external access. If you need remote admin access, use an SSH tunnel
or a Tailscale/Wireguard connection instead of exposing 5432 to
the LAN.
Create Per-App Users and Databases#
For each service that uses PostgreSQL, create a dedicated user
and database. Run these commands from the host:
1
|
docker exec -it homelab-postgres psql -U homelab
|
Then run these SQL commands inside psql:
1
2
3
4
|
-- Example: Immich user and database
CREATE USER immich WITH PASSWORD 'immich-strong-password';
CREATE DATABASE immich OWNER immich;
GRANT ALL PRIVILEGES ON DATABASE immich TO immich;
|
Connect to the new database and grant schema permissions:
1
|
docker exec homelab-postgres psql -U homelab -d immich -c "GRANT ALL ON SCHEMA public TO immich;"
|
1
2
3
4
|
-- Example: Nextcloud user and database
CREATE USER nextcloud WITH PASSWORD 'nextcloud-strong-password';
CREATE DATABASE nextcloud OWNER nextcloud;
GRANT ALL PRIVILEGES ON DATABASE nextcloud TO nextcloud;
|
This per-app isolation means a compromised application can only
access its own database, not the entire PostgreSQL instance.
Automated Backup Strategy with pg_dump#
PostgreSQL containers are ephemeral by nature — the data lives
in the named volume, but a docker compose down -v destroys it.
Regular automated backups are non-negotiable.
Backup Script#
Create a backup script that dumps all databases in custom format
(compressed, parallel-restore capable):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
# /opt/postgres/scripts/backup.sh
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/postgres/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=14
source /opt/postgres/.env
mkdir -p "$BACKUP_DIR"
# Dump all databases (globals + schema + data in one custom-format file)
docker exec homelab-postgres \
pg_dumpall -U "$POSTGRES_USER" \
--globals-only \
-f "/tmp/globals-${TIMESTAMP}.sql"
docker exec homelab-postgres \
pg_dump -U "$POSTGRES_USER" \
--format=custom \
--compress=9 \
--dbname="$POSTGRES_DB" \
-f "/tmp/full-${TIMESTAMP}.dump"
# Copy backups out of the container
docker cp "homelab-postgres:/tmp/globals-${TIMESTAMP}.sql" "${BACKUP_DIR}/"
docker cp "homelab-postgres:/tmp/full-${TIMESTAMP}.dump" "${BACKUP_DIR}/"
docker exec homelab-postgres rm "/tmp/globals-${TIMESTAMP}.sql" "/tmp/full-${TIMESTAMP}.dump"
# Prune backups older than retention period
find "$BACKUP_DIR" -name "*.dump" -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name "*.sql" -mtime +$RETENTION_DAYS -delete
echo "Backup complete: ${BACKUP_DIR}/full-${TIMESTAMP}.dump"
echo "Globals: ${BACKUP_DIR}/globals-${TIMESTAMP}.sql"
|
Make the script executable:
1
|
chmod +x /opt/postgres/scripts/backup.sh
|
Systemd Service and Timer#
Schedule daily backups with a systemd timer:
1
2
3
4
5
6
7
8
9
10
|
# /etc/systemd/system/postgres-backup.service
[Unit]
Description=PostgreSQL Docker Backup
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/opt/postgres/scripts/backup.sh
User=root
|
1
2
3
4
5
6
7
8
9
10
11
|
# /etc/systemd/system/postgres-backup.timer
[Unit]
Description=Daily PostgreSQL backup
[Timer]
OnCalendar=02:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
|
Enable and start the timer:
1
2
3
|
systemctl daemon-reload
systemctl enable --now postgres-backup.timer
systemctl list-timers postgres-backup
|
Restoring from Backup#
To restore on a new or failed container:
1
2
3
4
5
6
7
8
9
10
|
# Start a fresh PostgreSQL container (with same volume)
cd /opt/postgres && docker compose down -v && docker compose up -d
# Restore globals first
docker cp /opt/postgres/backups/globals-20260528_020000.sql homelab-postgres:/tmp/
docker exec homelab-postgres psql -U homelab -f /tmp/globals-20260528_020000.sql
# Restore data
docker cp /opt/postgres/backups/full-20260528_020000.dump homelab-postgres:/tmp/
docker exec homelab-postgres pg_restore -U homelab --dbname=homelab /tmp/full-20260528_020000.dump
|
The custom format supports parallel restore, which speeds recovery
on multi-core hosts:
1
|
docker exec homelab-postgres pg_restore -U homelab --dbname=homelab --jobs=4 /tmp/full-20260528_020000.dump
|
Optional: Streaming Replication#
For high-availability homelabs running critical services, add a
read-only replica. This provides query offloading for analytics or
read-heavy apps and a warm standby for failover.
The full replication setup requires a dedicated guide, but here is
the minimal docker-compose.yml extension for a second replica node:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# /opt/postgres-replica/docker-compose.yml
services:
postgres-replica:
image: postgres:16-alpine
container_name: homelab-postgres-replica
restart: unless-stopped
volumes:
- pgdata-replica:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_USER=${POSTGRES_USER:-homelab}
networks:
- db-net
depends_on:
postgres-primary:
condition: service_healthy
# entrypoint script handles pg_basebackup + recovery
volumes:
pgdata-replica:
networks:
db-net:
external: true
|
The primary needs wal_level = logical (already set in the config
above) and a replication slot:
1
|
SELECT * FROM pg_create_physical_replication_slot('replica_1');
|
Monitoring PostgreSQL in Docker#
Track database health and performance with these commands:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Connection count per database
docker exec homelab-postgres psql -U homelab -c \
"SELECT datname, numbackends FROM pg_stat_database ORDER BY numbackends DESC;"
# Active queries
docker exec homelab-postgres psql -U homelab -c \
"SELECT pid, state, query_start, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;"
# Table sizes
docker exec homelab-postgres psql -U homelab -c \
"SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;"
# Docker resource usage
docker stats homelab-postgres
|
For Prometheus-based monitoring, add prometheuscommunity/postgres-exporter
to the stack. Mount a queries.yaml file to expose custom metrics.
Updating PostgreSQL#
PostgreSQL point releases (16.1 → 16.2) are minor and safe to
upgrade in place:
1
2
3
|
cd /opt/postgres
docker compose pull postgres
docker compose up -d --force-recreate postgres
|
Major version upgrades (16 → 17) require a dump and restore.
Dump the old database, spin up the new container with a fresh
volume, and restore.
Conclusion#
A single, well-configured PostgreSQL 16 Docker container is the
most efficient database strategy for a multi-service homelab. It
centralizes backups, simplifies resource allocation, and gives
every application a reliable, tuned backend without running a
dozen separate database instances.
Start with the compose file and .env from this guide, add your
performance tuning values based on available RAM, set up the backup
timer, and connect your apps over the internal db-net network.
Once it runs for a week without issues, explore streaming replication
for redundancy and the postgres_exporter for Grafana dashboards.
Your homelab apps — Immich, Nextcloud, Gitea, Authentik, n8n,
Vaultwarden — will thank you for it.