PostgreSQL and MinIO for the platform, behind one TLS entry point

A Compose stack that publishes PostgreSQL, the MinIO S3 API and console, and
pgAdmin through Caddy, which is the only container with public ports. The data
services sit on an internal network.

Caddy is built with the pinned caddy-l4 module so it can terminate TLS on the
PostgreSQL wire protocol itself (SSLRequest), not only HTTP. MinIO is built from
its pinned upstream security release, which has no published image. Every
secret comes from .env, which is ignored; .env.example documents it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-10-02 09:32:05 +02:00
commit 09d1871a4b
8 changed files with 369 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
.env
.git
README.md

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
# All four names must resolve publicly to this server before the first start.
POSTGRES_DOMAIN=postgres.example.com
MINIO_API_DOMAIN=s3.example.com
MINIO_CONSOLE_DOMAIN=minio.example.com
PGADMIN_DOMAIN=pgadmin.example.com
TLS_CONTACT=admin@example.com
# Caddy is the only public entry point. Change the port only if clients also use it.
PUBLIC_BIND_ADDRESS=0.0.0.0
HTTP_PUBLIC_PORT=80
HTTPS_PUBLIC_PORT=443
POSTGRES_PUBLIC_PORT=5432
POSTGRES_DB=humainflow
POSTGRES_USER=humainflow
POSTGRES_PASSWORD=replace-with-a-long-random-password
# MinIO requires at least 3 characters for the user and 8 for the password;
# use substantially longer random values in production.
MINIO_ROOT_USER=humainflow-admin
MINIO_ROOT_PASSWORD=replace-with-an-independent-long-random-password
# Initial pgAdmin administrator. These values are used only when the
# pgadmin-data volume is created for the first time.
PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=replace-with-a-third-independent-random-password
# Optional image override. Keep the same major version when upgrading an existing volume.
POSTGRES_IMAGE=postgres:17.11-alpine

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Holds the real passwords and domains of one deployment; .env.example is the documented one.
.env
.DS_Store

60
Caddyfile Normal file
View File

@ -0,0 +1,60 @@
{
admin off
email {$TLS_CONTACT}
# Stock Caddy only proxies HTTP. The pinned caddy-l4 module also handles the
# PostgreSQL SSLRequest handshake, terminates TLS, then proxies cleartext only
# over the private Docker network.
layer4 {
:5432 {
@postgres postgres
route @postgres {
postgres_tls
tls
proxy postgres:5432
}
}
}
}
# Besides providing a useful status response, this site block tells Caddy to
# obtain and renew the certificate used by the Layer 4 PostgreSQL listener.
{$POSTGRES_DOMAIN} {
respond "PostgreSQL is available on port 5432 with TLS required.\n" 200
log {
output stdout
format json
}
}
# S3 API. Keep this on its own hostname: S3 request signatures include the host.
{$MINIO_API_DOMAIN} {
reverse_proxy minio:9000
log {
output stdout
format json
}
}
# Browser-based MinIO console, separated from the S3 API hostname.
{$MINIO_CONSOLE_DOMAIN} {
reverse_proxy minio:9001
log {
output stdout
format json
}
}
# Multi-user PostgreSQL administration UI. Authentication is handled by
# pgAdmin; database permissions remain the responsibility of PostgreSQL roles.
{$PGADMIN_DOMAIN} {
reverse_proxy pgadmin:5050
log {
output stdout
format json
}
}

83
README.md Normal file
View File

@ -0,0 +1,83 @@
# PostgreSQL and MinIO public data stack
This Compose stack publishes four encrypted endpoints through Caddy:
- PostgreSQL on `postgres.example.com:5432` using the native PostgreSQL TLS negotiation;
- the MinIO S3 API on `https://s3.example.com`;
- the MinIO console on `https://minio.example.com`;
- the multi-user pgAdmin interface on `https://pgadmin.example.com`.
PostgreSQL and MinIO have no directly published container ports. Caddy is the only public entry
point; traffic from Caddy to the services stays on an internal Docker network. Caddy persists its
ACME account, certificates and private keys in `caddy-data`, so restarts do not trigger unnecessary
certificate reissuance.
The Caddy image is built with the pinned `caddy-l4` module because stock Caddy only proxies HTTP.
The module understands PostgreSQL's initial `SSLRequest`, terminates TLS with Caddy's automatically
managed certificate, and sends the decrypted connection to PostgreSQL only on the private network.
The MinIO server is built from its latest upstream security release. Upstream did not publish a
container for that release, so the included multi-stage Dockerfile builds the pinned source tag.
pgAdmin is pinned to version 9.18 and runs in server mode. Its users, sessions, preferences and
saved server definitions live in the `pgadmin-data` volume.
## Start
1. Create public `A`/`AAAA` records for the four names and point them to the server.
2. Allow inbound TCP ports `80`, `443`, and `5432` in the host/cloud firewall. Restrict `5432` to
known client address ranges whenever possible.
3. Copy the environment template and replace every placeholder:
```sh
cp .env.example .env
openssl rand -base64 36
docker compose config --quiet
docker compose up -d --build
```
4. Follow certificate issuance and startup:
```sh
docker compose logs -f caddy postgres minio pgadmin
```
Ports 80 and 443 must reach Caddy from the public Internet for the usual ACME HTTP/TLS challenges.
The names must be eligible for Let's Encrypt issuance; a restrictive DNS CAA record can refuse it.
## Connect
PostgreSQL requires TLS at the public listener. `verify-full` both encrypts the connection and checks
that the certificate matches the hostname:
```sh
psql "host=postgres.example.com port=5432 dbname=humainflow user=humainflow sslmode=verify-full"
```
MinIO/S3 clients use `https://s3.example.com`; administrators open
`https://minio.example.com`. `MINIO_SERVER_URL` is set to the public S3 hostname so presigned URLs
remain valid behind the reverse proxy.
Open `https://pgadmin.example.com` and sign in with `PGADMIN_DEFAULT_EMAIL` and
`PGADMIN_DEFAULT_PASSWORD`. On the first login, register the database with these values:
- host: `postgres` (the Compose service name, not the public hostname);
- port: `5432`;
- maintenance database: the value of `POSTGRES_DB`;
- username/password: a PostgreSQL role and its password.
The initial pgAdmin administrator can create additional pgAdmin accounts from User Management.
pgAdmin accounts only control access to the web interface: create separate least-privilege
PostgreSQL roles for database authorization. Each person should use their own database role rather
than sharing `POSTGRES_USER`. Changing the default pgAdmin password in `.env` after the first start
does not update the account already stored in `pgadmin-data`; change it from pgAdmin instead.
## Operations
The persistent volumes are `postgres-data`, `minio-data`, `pgadmin-data`, and `caddy-data`. Back up
the first three; do not treat Docker volumes as backups. Never run `docker compose down -v` unless
permanent deletion of both data stores, pgAdmin's configuration and Caddy's certificate state is
intended.
Upgrade PostgreSQL one major version at a time using the PostgreSQL upgrade procedure. Updating an
image tag alone does not migrate an existing database volume.

9
caddy.Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM caddy:2.11.4-builder-alpine AS builder
# Layer 4 adds PostgreSQL protocol matching and SSLRequest/TLS termination.
RUN xcaddy build \
--with github.com/mholt/caddy-l4@v0.1.2
FROM caddy:2.11.4-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

158
docker-compose.yml Normal file
View File

@ -0,0 +1,158 @@
name: humainflow-data-stack
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
postgres:
image: ${POSTGRES_IMAGE:-postgres:17.11-alpine}
environment:
POSTGRES_DB: ${POSTGRES_DB:-humainflow}
POSTGRES_USER: ${POSTGRES_USER:-humainflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256
POSTGRES_HOST_AUTH_METHOD: scram-sha-256
command:
- postgres
- -c
- password_encryption=scram-sha-256
volumes:
- postgres-data:/var/lib/postgresql/data
expose:
- "5432"
networks:
- data-backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
shm_size: 256mb
restart: unless-stopped
stop_grace_period: 60s
logging: *default-logging
minio:
build:
context: .
dockerfile: minio.Dockerfile
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
# Required for presigned URLs generated while MinIO is behind Caddy.
MINIO_SERVER_URL: https://${MINIO_API_DOMAIN:?set MINIO_API_DOMAIN in .env}
MINIO_BROWSER_REDIRECT_URL: https://${MINIO_CONSOLE_DOMAIN:?set MINIO_CONSOLE_DOMAIN in .env}
command: ["server", "/data", "--console-address", ":9001"]
volumes:
- minio-data:/data
expose:
- "9000"
- "9001"
networks:
- data-backend
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
restart: unless-stopped
stop_grace_period: 60s
logging: *default-logging
pgadmin:
image: dpage/pgadmin4:9.18
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?set PGADMIN_DEFAULT_EMAIL in .env}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?set PGADMIN_DEFAULT_PASSWORD in .env}
# Caddy terminates TLS. pgAdmin listens only on the private Docker network.
PGADMIN_LISTEN_ADDRESS: 0.0.0.0
PGADMIN_LISTEN_PORT: 5050
PGADMIN_DISABLE_POSTFIX: "true"
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "True"
PGADMIN_CONFIG_SESSION_COOKIE_SECURE: "True"
PGADMIN_CONFIG_UPGRADE_CHECK_ENABLED: "False"
volumes:
- pgadmin-data:/var/lib/pgadmin
expose:
- "5050"
networks:
- data-backend
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:5050/misc/ping"]
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 512m
cpus: 1
restart: unless-stopped
logging: *default-logging
caddy:
build:
context: .
dockerfile: caddy.Dockerfile
environment:
TLS_CONTACT: ${TLS_CONTACT:?set TLS_CONTACT in .env}
POSTGRES_DOMAIN: ${POSTGRES_DOMAIN:?set POSTGRES_DOMAIN in .env}
MINIO_API_DOMAIN: ${MINIO_API_DOMAIN:?set MINIO_API_DOMAIN in .env}
MINIO_CONSOLE_DOMAIN: ${MINIO_CONSOLE_DOMAIN:?set MINIO_CONSOLE_DOMAIN in .env}
PGADMIN_DOMAIN: ${PGADMIN_DOMAIN:?set PGADMIN_DOMAIN in .env}
ports:
# 80/443 are used for ACME challenges, HTTPS and redirects.
- "${PUBLIC_BIND_ADDRESS:-0.0.0.0}:${HTTP_PUBLIC_PORT:-80}:80"
- "${PUBLIC_BIND_ADDRESS:-0.0.0.0}:${HTTPS_PUBLIC_PORT:-443}:443"
# PostgreSQL-over-TLS is terminated by Caddy's Layer 4 module.
- "${PUBLIC_BIND_ADDRESS:-0.0.0.0}:${POSTGRES_PUBLIC_PORT:-5432}:5432"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Certificates, private keys and the ACME account must survive restarts.
- caddy-data:/data
- caddy-config:/config
networks:
- edge
- data-backend
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
pgadmin:
condition: service_healthy
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
read_only: true
pids_limit: 128
mem_limit: 256m
cpus: 1
restart: unless-stopped
logging: *default-logging
networks:
edge:
data-backend:
internal: true
volumes:
postgres-data:
minio-data:
pgadmin-data:
caddy-data:
caddy-config:

24
minio.Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM golang:1.25.1-alpine3.22 AS builder
ARG MINIO_VERSION=RELEASE.2025-10-15T17-29-55Z
RUN apk add --no-cache bash git
RUN git clone --branch "${MINIO_VERSION}" --depth 1 https://github.com/minio/minio.git /src
WORKDIR /src
RUN LDFLAGS="$(MINIO_RELEASE=RELEASE go run buildscripts/gen-ldflags.go)" && \
CGO_ENABLED=0 go build -tags kqueue -trimpath --ldflags "${LDFLAGS}" -o /out/minio .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates && \
addgroup -S -g 1000 minio && \
adduser -S -D -H -u 1000 -G minio minio && \
install -d -o minio -g minio /data
COPY --from=builder /out/minio /usr/local/bin/minio
USER minio:minio
EXPOSE 9000 9001
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/minio"]