Go to file
Lucio Lelii c576a44e6f Pull MinIO and Caddy instead of compiling them on the server
Both images had to be compiled where the stack runs - MinIO from source because upstream
publishes no image for the security release, Caddy through xcaddy for the caddy-l4 module.
On a small VM that failed: the Go builds run out of disk and memory, and an install that has
to compile two programs is a poor way to start a database.

They are now built once, for amd64 and arm64, and published to Docker Hub as
luciolelii/minio and luciolelii/caddy-l4. Each tag is the version its Dockerfile pins, read
from the Dockerfile by publish-images.sh so the two cannot drift: RELEASE.2025-10-15T17-29-55Z
for MinIO, and 2.11.4-l4-v0.1.2 (Caddy, then the module) for Caddy. docker-compose.yml names
those tags, overridable with MINIO_IMAGE and CADDY_IMAGE. The Dockerfiles stay, as the source
of the images.

The MinIO image still runs as 1000:1000, so what the README says about the data directory's
owner stands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 12:06:01 +02:00
.dockerignore PostgreSQL and MinIO for the platform, behind one TLS entry point 2026-10-02 09:32:05 +02:00
.env.example Pull MinIO and Caddy instead of compiling them on the server 2026-10-02 12:06:01 +02:00
.gitignore PostgreSQL and MinIO for the platform, behind one TLS entry point 2026-10-02 09:32:05 +02:00
Caddyfile Serve the whole stack under one host name 2026-10-02 10:27:07 +02:00
README.md Pull MinIO and Caddy instead of compiling them on the server 2026-10-02 12:06:01 +02:00
caddy.Dockerfile PostgreSQL and MinIO for the platform, behind one TLS entry point 2026-10-02 09:32:05 +02:00
docker-compose.yml Pull MinIO and Caddy instead of compiling them on the server 2026-10-02 12:06:01 +02:00
minio.Dockerfile PostgreSQL and MinIO for the platform, behind one TLS entry point 2026-10-02 09:32:05 +02:00
publish-images.sh Pull MinIO and Caddy instead of compiling them on the server 2026-10-02 12:06:01 +02:00

README.md

PostgreSQL and MinIO public data stack

This Compose stack publishes four encrypted endpoints through Caddy, all on one host name (DATA_DOMAIN, here data.example.com), each on its own port:

Service Address
MinIO S3 API https://data.example.com (443)
PostgreSQL data.example.com:5432, using the native PostgreSQL TLS negotiation
MinIO console https://data.example.com:9443
pgAdmin (multi-user) https://data.example.com:5443

One name means one DNS record and one certificate: a certificate is issued for a name, not a port, so every port above presents the same one. The S3 API is the one on the standard port because it is what other systems call and MinIO serves buckets from the root of its host. Opening https://data.example.com/ in a browser therefore shows an XML error from MinIO; that is the API answering, not a fault.

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.

Two of the images are not stock ones, so they are built once, from caddy.Dockerfile and minio.Dockerfile, and published to Docker Hub; the server only pulls them and compiles nothing.

  • luciolelii/caddy-l4 is Caddy 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.
  • luciolelii/minio is MinIO built from its latest upstream security release. Upstream did not publish a container for that release, so the Dockerfile builds the pinned source tag.

Each image’s tag is the version its Dockerfile pins (RELEASE.2025-10-15T17-29-55Z, and 2.11.4-l4-v0.1.2 for Caddy version then module version), and docker-compose.yml names that tag. Both are built for amd64 and arm64.

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 DATA_DOMAIN and point them to the server.

  2. Allow inbound TCP ports 80, 443, 5432, 9443 and 5443 in the host/cloud firewall. Restrict 5432, 9443 and 5443 to known client address ranges whenever possible: only 80 and 443 have to be open to the whole Internet, for certificate issuance.

  3. Copy the environment template and replace every placeholder:

    cp .env.example .env
    openssl rand -base64 36
    docker compose config --quiet
    docker compose pull
    docker compose up -d
  4. Follow certificate issuance and startup:

    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 name 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:

psql "host=data.example.com port=5432 dbname=humainflow user=humainflow sslmode=verify-full"

MinIO/S3 clients use https://data.example.com with path-style addressing (https://data.example.com/<bucket>/<key>); administrators open https://data.example.com:9443. MINIO_SERVER_URL is set to the public S3 address so presigned URLs remain valid behind the reverse proxy, and MINIO_BROWSER_REDIRECT_URL carries the console’s port.

Open https://data.example.com:5443 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.

Storing the data on another disk

By default PostgreSQL and MinIO keep their data in the Docker volumes postgres-data and minio-data, which live under /var/lib/docker on the system disk. To use a larger disk, mount it on the host and point the two variables at directories on it:

POSTGRES_DATA_PATH=/mnt/bigdisk/postgres
MINIO_DATA_PATH=/mnt/bigdisk/minio

The two are independent: set one or both. Unset (or empty) means the named volume, as before.

  1. Create the directories inside the disk, not at its mount point, and give them to the user the container runs as. On ext4 the mount point holds lost+found, and PostgreSQL will not start in a directory that is not empty.

    sudo mkdir -p /mnt/bigdisk/postgres /mnt/bigdisk/minio
    sudo chown 70:70 /mnt/bigdisk/postgres && sudo chmod 700 /mnt/bigdisk/postgres   # postgres (alpine)
    sudo chown 1000:1000 /mnt/bigdisk/minio                                          # MinIO, see minio.Dockerfile
  2. A new installation needs nothing more: set the variables and docker compose up -d.

  3. An installation that already holds data must copy it first. Do not use -v anywhere:

    docker compose down
    docker run --rm -v humainflow-data-stack_postgres-data:/from -v /mnt/bigdisk/postgres:/to alpine cp -a /from/. /to/
    docker run --rm -v humainflow-data-stack_minio-data:/from    -v /mnt/bigdisk/minio:/to    alpine cp -a /from/. /to/
    # set the two variables in .env, then
    docker compose up -d

    The volume names carry the project name from name: in the compose file. Keep the old volumes until everything works, and remove them only then.

Cautions:

  • Use a local or block disk. PostgreSQL on a network filesystem such as NFS risks corruption.
  • Mount the disk at boot (/etc/fstab, with nofail) and start Docker after it. If the disk is missing when the stack starts, Docker creates the directory on the system disk and PostgreSQL starts empty - it looks like the data is gone, and it is only somewhere else.
  • A backup of POSTGRES_DATA_PATH taken while PostgreSQL runs is not consistent; use pg_dump or a proper base backup.

Upgrading from the four-name layout

An earlier version used POSTGRES_DOMAIN, MINIO_API_DOMAIN, MINIO_CONSOLE_DOMAIN and PGADMIN_DOMAIN. Replace them in .env with a single DATA_DOMAIN (any one of the old names will do, as long as it resolves to this server), open ports 9443 and 5443, and run docker compose up -d. Data volumes are untouched. Addresses change: the console moves from its own name to :9443, pgAdmin to :5443, and PostgreSQL clients connect to DATA_DOMAIN.

Publishing the MinIO and Caddy images

Only needed to change a version. Edit the version in the Dockerfile (ARG MINIO_VERSION in minio.Dockerfile; the caddy: tag and the caddy-l4@ version in caddy.Dockerfile), then, from a machine with Docker and a Docker Hub login (docker login -u luciolelii, with an access token that can write):

./publish-images.sh            # both; or: ./publish-images.sh minio   ./publish-images.sh caddy

and set the new tag in docker-compose.yml (or MINIO_IMAGE / CADDY_IMAGE in .env). If the Docker Hub repositories are private, run docker login on the server before docker compose pull.