Self-Hosted Document Generation: Docker, PostgreSQL and MinIO Setup
Not every organization can send document data to a cloud API. Regulatory requirements, data residency laws, or simply a preference for infrastructure ownership all drive teams toward self-hosted solutions. Running your own document generation stack is entirely feasible with modern container tooling, but it requires thoughtful architecture to avoid operational pain.
This guide walks through deploying a complete document generation API using Docker Compose with three services: the API application, PostgreSQL for metadata and configuration, and MinIO for S3-compatible document storage. By the end, you will have a production-ready stack running on a single server.
Architecture: Three Services, One Network
The self-hosted stack consists of three containers communicating on a private Docker network:
- Document API — The Java application that processes templates and renders PDFs. Exposes port 8080 for API requests.
- PostgreSQL — Stores API keys, template metadata, generation logs, and usage analytics. Exposes port 5432 internally only.
- MinIO — S3-compatible object storage for generated documents and uploaded templates. Exposes port 9000 for the API and 9001 for the admin console.
A reverse proxy (Nginx or Caddy) sits in front, terminating TLS and routing requests to the API service. This proxy is not included in the Docker Compose file because most organizations already have a proxy layer or load balancer.
Docker Compose Configuration
version: "3.8"
services:
api:
image: doxnex/document-api:latest
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/doxnex
- SPRING_DATASOURCE_USERNAME=doxnex
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
- MINIO_ENDPOINT=http://minio:9000
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=documents
- API_ENCRYPTION_KEY=${ENCRYPTION_KEY}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G
cpus: "2.0"
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- POSTGRES_DB=doxnex
- POSTGRES_USER=doxnex
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U doxnex"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
volumes:
- miniodata:/data
environment:
- MINIO_ROOT_USER=${MINIO_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY}
ports:
- "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G
volumes:
pgdata:
miniodata:
Environment Configuration
Create a .env file alongside your docker-compose.yml. Never commit this file to version control.
# .env
DB_PASSWORD=your-strong-database-password-here
MINIO_ACCESS_KEY=doxnex-minio-admin
MINIO_SECRET_KEY=your-strong-minio-password-here
ENCRYPTION_KEY=your-32-byte-hex-encryption-key
The ENCRYPTION_KEY is used to encrypt sensitive data at rest in PostgreSQL, such as webhook secrets and customer metadata. Use a cryptographically random 32-byte key, generated with openssl rand -hex 32.
PostgreSQL Schema and Initialization
The init.sql file runs on first startup to create the required tables. On subsequent starts, PostgreSQL skips it because the database already exists.
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prefix VARCHAR(16) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
account_id UUID NOT NULL,
tier VARCHAR(32) NOT NULL DEFAULT 'free',
rate_limit_per_minute INT NOT NULL DEFAULT 10,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_prefix ON api_keys(prefix);
CREATE INDEX idx_api_keys_account ON api_keys(account_id);
CREATE TABLE generation_logs (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID REFERENCES api_keys(id),
template_name VARCHAR(255),
format VARCHAR(16),
duration_ms INT,
file_size_bytes BIGINT,
status VARCHAR(16),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_gen_logs_created ON generation_logs(created_at);
MinIO: S3-Compatible Object Storage
MinIO provides an S3-compatible API, which means your application code is identical whether it targets MinIO, AWS S3, Google Cloud Storage (via S3 compatibility), or any other provider. This portability is the main reason to choose MinIO over direct filesystem storage.
Initial Bucket Setup
After the first startup, create the documents bucket using the MinIO client:
# Install MinIO client
docker run --rm --network host minio/mc \
alias set local http://localhost:9000 \
${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY}
docker run --rm --network host minio/mc \
mb local/documents
# Set lifecycle policy: delete documents after 30 days
docker run --rm --network host minio/mc \
ilm rule add local/documents \
--expire-days 30
Retention and Lifecycle
Generated documents rarely need to be stored indefinitely. Set a lifecycle policy to automatically delete documents after 30, 60, or 90 days depending on your use case. Customers can download documents immediately after generation or configure webhook delivery. Long-term archival should use a separate, cheaper storage tier.
Networking and Security
TLS Termination
Place a reverse proxy (Caddy is the simplest option, with automatic Let's Encrypt certificates) in front of the API service. The internal Docker network uses plain HTTP; encryption is handled at the edge.
# Caddyfile
api.yourdomain.com {
reverse_proxy localhost:8080
}
minio-console.yourdomain.com {
reverse_proxy localhost:9001
}
Firewall Rules
Only expose ports 80 and 443 (via the reverse proxy) to the internet. PostgreSQL (5432) and the MinIO API (9000) should never be accessible externally. The MinIO console (9001) should only be accessible from your management network or via a VPN.
Monitoring and Backups
Health Checks
The Docker Compose file includes health checks for PostgreSQL and MinIO. Add an application-level health check endpoint at /actuator/health that verifies connectivity to both dependencies.
Backup Strategy
- PostgreSQL: Run
pg_dumpdaily via a cron job. Store backups in a separate MinIO bucket or an off-site location. Test restoration monthly. - MinIO: Enable bucket versioning for accidental deletion protection. Use
mc mirrorto replicate to a secondary MinIO instance or cloud S3 bucket. - Docker volumes: Use named volumes (as shown in the Compose file) on a persistent filesystem. Never rely on container-local storage.
Scaling Beyond a Single Server
The Docker Compose setup works for up to approximately 50 documents per second on a 4-core server. Beyond that, you have several options:
- Horizontal scaling: Run multiple API containers behind a load balancer. PostgreSQL and MinIO remain as shared services.
- Kubernetes migration: Convert the Compose file to Kubernetes manifests. Use StatefulSets for PostgreSQL and MinIO, Deployments with HPA for the API.
- Managed services: Replace PostgreSQL with Amazon RDS or Cloud SQL, and MinIO with native S3. Keep only the API container self-hosted.
For organizations that want the features of a self-hosted deployment without the operational overhead, Doxnex Cloud provides the same API with managed infrastructure, automatic scaling, and built-in monitoring.
Frequently Asked Questions
Why use MinIO instead of storing PDFs directly on disk?
MinIO provides an S3-compatible API, meaning your application code works identically whether you use MinIO locally, AWS S3 in production, or any other S3-compatible provider. It also handles concurrent access, data integrity, and lifecycle policies. If you later migrate to a cloud provider, you change only the endpoint URL and credentials, not your application code.
What are the minimum hardware requirements for self-hosting?
For a development or low-traffic deployment: 2 CPU cores, 4 GB RAM, and 20 GB SSD storage. For production handling up to 50 documents per second: 4 CPU cores, 8 GB RAM, and 100 GB SSD. PostgreSQL and MinIO each need at least 1 GB RAM. The document API service itself needs 2-4 GB depending on concurrent renders.
How do I back up the self-hosted deployment?
Back up PostgreSQL using pg_dump on a daily schedule. For MinIO, enable bucket versioning and use mc mirror to replicate data to a secondary location. Store backups off-site. The Docker volumes for both services should be on persistent storage, not ephemeral container filesystems.
Can I run this on Kubernetes instead of Docker Compose?
Yes. The Docker Compose configuration translates directly to Kubernetes manifests. Use StatefulSets for PostgreSQL and MinIO with PersistentVolumeClaims for data. Use a Deployment with horizontal pod autoscaling for the document API service. Helm charts simplify this migration significantly.