Skip to main content

Docker Compose: PostgreSQL and MinIO

This stack runs the site with a real database server (PostgreSQL) and S3-compatible object storage (MinIO), each in its own container. It suits a larger choir, a site that may later run several copies, or anyone who wants the database and files looked after by their own tools. It is for whoever manages the server. For a small choir the SQLite set-up is simpler.

You need Docker with the Compose plugin, and the folder from Run it in Docker with its Dockerfile and .dockerignore.

info

What was tested. For this guide the app and the PostgreSQL service were run together: the app connected, created its tables in PostgreSQL and started. The MinIO images could not be pulled in the test, which is why a note below is about that. The MariaDB and Redis blocks were not run.

The Compose file​

Create docker-compose.yml in the folder:

services:
app:
build: .
image: choir-manager
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
PORT: 3001
DB_PROVIDER: postgres
DATABASE_URL: postgres://${POSTGRES_USER:-choir}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-choir}
SESSION_STORE: ${SESSION_STORE:-database}
STORAGE_PROVIDER: s3
S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1
S3_FORCE_PATH_STYLE: "true"
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-choir}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
S3_PUBLIC_BUCKET: ${S3_PUBLIC_BUCKET:-choir-public}
S3_PRIVATE_BUCKET: ${S3_PRIVATE_BUCKET:-choir-private}
ports:
- "${HTTP_PORT:-8080}:3001"
volumes:
- data:/app/data
depends_on:
db:
condition: service_healthy
minio-setup:
condition: service_completed_successfully

db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-choir}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-choir}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-choir} -d ${POSTGRES_DB:-choir}"]
interval: 5s
timeout: 5s
retries: 20

# To use MySQL/MariaDB instead: comment out "db" above, uncomment this, and
# in the app service set DB_PROVIDER: mysql and
# DATABASE_URL: mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
# mysql:
# image: mariadb:11
# restart: unless-stopped
# environment:
# MARIADB_USER: ${MYSQL_USER:-choir}
# MARIADB_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
# MARIADB_DATABASE: ${MYSQL_DATABASE:-choir}
# MARIADB_RANDOM_ROOT_PASSWORD: "yes"
# volumes:
# - mysql-data:/var/lib/mysql
# healthcheck:
# test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
# interval: 5s
# timeout: 5s
# retries: 20

minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-choir}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
volumes:
- minio-data:/data
# Uncomment to reach the MinIO console at http://localhost:9001
# ports:
# - "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 20

# Creates the two buckets on first start. The public bucket stays private
# too: the app serves its files itself at /files/<key>, so no bucket policy
# is needed. (Set PUBLIC_FILES_URL to serve them from MinIO or a CDN instead.)
minio-setup:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-choir} ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env} &&
mc mb --ignore-existing local/${S3_PUBLIC_BUCKET:-choir-public} &&
mc mb --ignore-existing local/${S3_PRIVATE_BUCKET:-choir-private}
"

# Optional: Redis for sessions (set SESSION_STORE=redis and REDIS_URL=redis://redis:6379 in .env)
# redis:
# image: redis:7-alpine
# restart: unless-stopped
# volumes:
# - redis-data:/data

# Optional: HTTPS with automatic Let's Encrypt certificates.
# Set DOMAIN in .env and run with --profile tls.
caddy:
image: caddy:2-alpine
profiles: ["tls"]
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN:?set DOMAIN in .env to use the tls profile}
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- app

volumes:
data:
db-data:
minio-data:
caddy-data:
caddy-config:
# mysql-data:
# redis-data:

This is the stack that ships with the product's source, with one change: the app service has a data volume, added by this guide (see "The app needs a data volume" below). The Caddy service reads ./deploy/Caddyfile; make that file as described in Docker: HTTPS, ports and volumes, or remove the caddy service if you do not use it.

The .env file​

Compose reads this file for the ${...} values above, and the app also gets all of it. Add to the file from Run it in Docker:

HTTP_PORT=8080
POSTGRES_PASSWORD=change-me
MINIO_ROOT_PASSWORD=change-me-too
DOMAIN=choir.example.org
VariableUsed forDefault
HTTP_PORTThe host port that reaches the app8080
POSTGRES_USERThe database userchoir
POSTGRES_PASSWORDIts password. Required.none
POSTGRES_DBThe database namechoir
MINIO_ROOT_USERMinIO's login, which the app also uses as its storage keychoir
MINIO_ROOT_PASSWORDIts password, also the app's storage secret. Required.none
S3_PUBLIC_BUCKET, S3_PRIVATE_BUCKETThe two bucket nameschoir-public, choir-private
SESSION_STOREdatabase, or redisdatabase
DOMAINThe domain, for the tls profile. Compose asks for it even when you do not use that profile, see belownone

Everything else in .env (SITE_URL, ADMIN_USERNAME, the email settings, LICENSE_KEY) is passed to the app. Do not repeat the database and storage settings there: the Compose file sets them.

If a required one is missing, Compose stops before starting anything:

required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env

A password with characters such as @, /, : or # breaks the database address built from it. Use only letters and digits, or write the characters URL-encoded.

warning

Set DOMAIN even if you do not use HTTPS from Compose. Compose checks every service, including the ones in a profile you did not switch on. In the test, docker compose up stopped with error while interpolating services.caddy.environment.DOMAIN: required variable DOMAIN is missing a value until a DOMAIN line was in .env.

What the services do​

ServiceRole
appThe site and its API, built from your Dockerfile.
dbPostgreSQL 16, with its data in the db-data volume. It has a health check, and the app waits for it.
minioThe S3-compatible store, with its data in the minio-data volume.
minio-setupRuns once on start: makes the two buckets and exits. The app waits for it to finish.
caddyOptional HTTPS, only with --profile tls.

Both buckets are private. The app serves public files itself at /files/<key>, so no bucket policy is needed. To serve them from MinIO or a CDN, set PUBLIC_FILES_URL: see Public file addresses and upload limits.

Bring it up​

docker compose up -d --build
docker compose ps
docker compose logs app

The log should show [db] schema is up to date (postgres). The site is at http://localhost:8080. Then see Put it behind HTTPS.

Run the helper commands with docker compose exec app npm run config:check, in the same way as on the SQLite page.

The app needs a data volume​

The app service in the product's own file has no volume for /app/data. The image declares /app/data as a volume, so Docker quietly makes a nameless one for the container. That nameless volume holds downloaded releases (releases/), and is awkward to find and to keep when you recreate the container. Two ways to be safe, and this page's file does the first:

  1. Give it a named volume, as in the file above (data:/app/data and data: under volumes). One-click updates then survive.
  2. Or turn one-click updates off for this stack: set UPDATE_MODE=notify in .env, and update by building a new image. See Update a Docker site.

With PostgreSQL and MinIO, nothing else is kept in /app/data.

If MinIO will not download​

When this page was tested, docker compose up failed with pull access denied for minio/minio for the images minio/minio:latest and minio/mc:latest named in the file. If that happens to you, change the two image: lines to a MinIO image you can pull, or leave MinIO out and use a managed store (below). No replacement image has been tested for this guide.

Use MariaDB or MySQL instead of PostgreSQL​

  1. Comment out the db service and uncomment mysql.
  2. In the app service set DB_PROVIDER: mysql and DATABASE_URL: mysql://${MYSQL_USER:-choir}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE:-choir}, and change depends_on from db to mysql.
  3. Uncomment mysql-data: under volumes.
  4. Add MYSQL_PASSWORD (required), and optionally MYSQL_USER and MYSQL_DATABASE (both default to choir), to .env.

See MySQL and MariaDB.

Add Redis for sessions​

Uncomment the redis service and redis-data: under volumes, then in .env set:

SESSION_STORE=redis
REDIS_URL=redis://redis:6379

Add redis to the app's depends_on. See Sessions and how long people stay logged in. Most sites do not need this: sessions in the database work with several copies of the app.

Use a managed database or storage instead​

Remove the db service, or the minio and minio-setup services, and point the app at the hosted service. Delete the matching depends_on entries and the lines in environment: that you replace. For the database, set DATABASE_URL (and DATABASE_SSL=true if the service needs it). For files, set the S3_* variables. See PostgreSQL and S3-compatible storage.

Next​