Self-Hosted Document Generation: Docker, PostgreSQL and MinIO Setup

Published March 18, 2026 · 10 min read · Doxnex Engineering

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:

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

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:

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.