QORSTACKREPORT
How it worksFeaturesDevelopersPricingDocs
Star—Get started
Self-host guideDocker · MIT

Clone. Init.
Compose up.

Three commands: clone the repo, run init.sh to create .env with random secrets, then docker compose up -d. MIT-licensed. No usage limits. Your data stays on your network.

Start in 3 commandsView on GitHub
~/selfhost — the whole thing
git clone https://github.com/qorstack/qorstack-report.git
cd qorstack-report/selfhost
bash init.sh           # .env with random secrets; prints the admin password
docker compose up -d
License
MIT
Telemetry
Zero
Pro
Self-hosted
Status
Production ready
docker compose up -d■PostgreSQL■RustFS · MinIO · S3■Gotenberg■API keys per project■Generation history■MIT core■docker compose up -d■PostgreSQL■RustFS · MinIO · S3■Gotenberg■API keys per project■Generation history■MIT core■

01 — Requirements

Anything that runs Docker.

Docker
24+
Docker Compose
v2+
RAM
2 GB
Disk
5 GB

02 — Quick start

Zero to running in 4 steps.

  1. Step01

    Get the self-host folder

    The selfhost/ folder holds docker-compose.yml, a commented .env.example and the helper scripts. Nothing is built locally — Docker pulls prebuilt images (amd64 + arm64).
    git clone https://github.com/qorstack/qorstack-report.git
    cd qorstack-report/selfhost
    Prefer not to use git? Download the files instead

    Put both files (and init.sh) in one folder. The storage overrides docker-compose.minio.yml / docker-compose.external-s3.yml are only needed if you don’t use the bundled RustFS.

    docker-compose.yml
    Download
    1services:
    2  postgres:
    3    image: postgres:16.13-alpine3.23
    4    container_name: qorstack-postgres
    5    restart: unless-stopped
    6    environment:
    7      - POSTGRES_DB=qorstack_report
    8      - POSTGRES_USER=postgres
    9      - POSTGRES_PASSWORD=${DB_PASSWORD}
    10    # Not published to the host — only the app talks to it, inside the Docker network
    11    # (so nothing clashes with a PostgreSQL already running on this machine).
    12    # Need it from a desktop DB client? Uncomment (localhost only; pick another host port if 5432 is taken):
    13    # ports:
    14    #   - "127.0.0.1:${DB_HOST_PORT:-5432}:5432"
    15    volumes:
    16      - postgres_data:/var/lib/postgresql/data
    17    networks:
    18      - qorstack-network
    19    healthcheck:
    20      test: ["CMD-SHELL", "pg_isready -U postgres"]
    21      interval: 10s
    22      timeout: 5s
    23      retries: 5
    24
    25  # ── S3 storage: RustFS (Apache-2.0) starts by default ─────────────────────
    26  # Want MinIO or an external S3 instead? Add ONE override file (see the comments in .env.example):
    27  #   COMPOSE_FILE=docker-compose.yml:docker-compose.minio.yml        bundled MinIO (keeps v1 data)
    28  #   COMPOSE_FILE=docker-compose.yml:docker-compose.external-s3.yml  AWS S3 / R2 / your own server
    29  # The bundled server answers on the network alias "qorstack-storage", so STORAGE_ENDPOINT stays the same.
    30  # Storage is NOT published to the host: downloads go through the API (/files/...).
    31  rustfs:
    32    image: rustfs/rustfs:1.0.0
    33    container_name: qorstack-rustfs
    34    restart: unless-stopped
    35    environment:
    36      - RUSTFS_ACCESS_KEY=${STORAGE_ACCESS_KEY:-${MINIO_ACCESS_KEY:-qorstack}}
    37      - RUSTFS_SECRET_KEY=${STORAGE_SECRET_KEY:-${MINIO_SECRET_KEY:-}}
    38      - RUSTFS_VOLUMES=/data
    39      - RUSTFS_ADDRESS=0.0.0.0:9000
    40      - RUSTFS_CONSOLE_ENABLE=true
    41      - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
    42    # Uncomment to open the RustFS web console on http://localhost:9001
    43    # ports:
    44    #   - "9001:9001"
    45    volumes:
    46      - rustfs_data:/data
    47    networks:
    48      qorstack-network:
    49        aliases: [qorstack-storage]
    50    healthcheck:
    51      # The image ships curl; /health is RustFS's S3-port health endpoint.
    52      test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"]
    53      interval: 10s
    54      timeout: 5s
    55      retries: 6
    56
    57  # Mirrors uploaded fonts (STORAGE_BUCKET/fonts/) into the volume Gotenberg reads, every 30 s.
    58  # rclone speaks plain S3, so it works with RustFS, MinIO and any external S3.
    59  font-syncer:
    60    image: rclone/rclone:1.74
    61    container_name: qorstack-font-syncer
    62    restart: unless-stopped
    63    entrypoint: ["/bin/sh", "-c"]
    64    command:
    65      - |
    66        case "$$STORAGE_ENDPOINT" in http://*|https://*) EP="$$STORAGE_ENDPOINT" ;;
    67          *) if [ "$$STORAGE_USE_SSL" = "true" ]; then EP="https://$$STORAGE_ENDPOINT"; else EP="http://$$STORAGE_ENDPOINT"; fi ;; esac
    68        export RCLONE_CONFIG_STORE_ENDPOINT="$$EP"
    69        echo '[font-syncer] syncing $$STORAGE_BUCKET/fonts every 30s'
    70        while true; do
    71          rclone sync "store:$$STORAGE_BUCKET/fonts" /fonts-cache \
    72            --include "*.{ttf,otf,woff,woff2,TTF,OTF,WOFF,WOFF2}" --s3-no-check-bucket -q \
    73            || echo '[font-syncer] storage not reachable yet, retrying'
    74          sleep 30
    75        done
    76    environment:
    77      - STORAGE_BUCKET=${STORAGE_BUCKET:-${MINIO_BUCKET:-qorstack-report}}
    78      - RCLONE_CONFIG_STORE_TYPE=s3
    79      - RCLONE_CONFIG_STORE_PROVIDER=Other
    80      - STORAGE_ENDPOINT=${STORAGE_ENDPOINT:-${MINIO_ENDPOINT:-qorstack-storage:9000}}
    81      - STORAGE_USE_SSL=${STORAGE_USE_SSL:-${MINIO_USE_SSL:-false}}
    82      - RCLONE_CONFIG_STORE_ACCESS_KEY_ID=${STORAGE_ACCESS_KEY:-${MINIO_ACCESS_KEY:-qorstack}}
    83      - RCLONE_CONFIG_STORE_SECRET_ACCESS_KEY=${STORAGE_SECRET_KEY:-${MINIO_SECRET_KEY:-}}
    84      - RCLONE_CONFIG_STORE_REGION=${STORAGE_REGION:-us-east-1}
    85      - RCLONE_CONFIG_STORE_FORCE_PATH_STYLE=true
    86    volumes:
    87      - fonts_cache:/fonts-cache
    88    networks:
    89      - qorstack-network
    90
    91  gotenberg:
    92    image: qorstack/gotenberg:8
    93    container_name: qorstack-gotenberg
    94    restart: unless-stopped
    95    command:
    96      - "gotenberg"
    97      - "--api-port=3000"
    98      - "--log-level=warn"
    99      - "--libreoffice-auto-start=true"
    100      - "--libreoffice-restart-after=100"
    101      - "--api-disable-health-check-logging=true"
    102    volumes:
    103      - fonts_cache:/usr/local/share/fonts/custom
    104    networks:
    105      - qorstack-network
    106    healthcheck:
    107      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
    108      interval: 30s
    109      timeout: 10s
    110      retries: 3
    111
    112  # Web UI + API in ONE container on ONE port: open http://localhost:8080 — the API is http://localhost:8080/api
    113  app:
    114    image: qorstack/report:${REPORT_TAG:-latest}
    115    container_name: qorstack-report
    116    restart: unless-stopped
    117    ports:
    118      - "${APP_PORT:-8080}:8080"
    119    environment:
    120      - ASPNETCORE_ENVIRONMENT=Production
    121      - Admin__Email=${ADMIN_EMAIL}
    122      - Admin__Password=${ADMIN_PASSWORD}
    123      - ConnectionStrings__DefaultConnection=Host=${DB_HOST:-qorstack-postgres};Port=${DB_PORT:-5432};Database=${DB_NAME:-qorstack_report};Username=${DB_USER:-postgres};Password=${DB_PASSWORD}
    124      - Database__MigrationPaths__0=database
    125      # S3 storage — STORAGE_* (v1 MINIO_* names still work as fallbacks)
    126      - Storage__Endpoint=${STORAGE_ENDPOINT:-${MINIO_ENDPOINT:-qorstack-storage:9000}}
    127      - Storage__AccessKey=${STORAGE_ACCESS_KEY:-${MINIO_ACCESS_KEY:-qorstack}}
    128      - Storage__SecretKey=${STORAGE_SECRET_KEY:-${MINIO_SECRET_KEY:-}}
    129      - Storage__UseSsl=${STORAGE_USE_SSL:-${MINIO_USE_SSL:-false}}
    130      - Storage__Region=${STORAGE_REGION:-us-east-1}
    131      - Storage__Bucket=${STORAGE_BUCKET:-${MINIO_BUCKET:-qorstack-report}}
    132      - Storage__PublicEndpoint=${STORAGE_PUBLIC_ENDPOINT:-${MINIO_PUBLIC_ENDPOINT:-}}
    133      # URL people open (web UI + API). Download links are {PUBLIC_URL}/api/files/...
    134      - App__PublicUrl=${PUBLIC_URL:-http://localhost:8080}
    135      - Render__AsyncWorkers=${RENDER_ASYNC_WORKERS:-2}
    136      - Render__JobLeaseSeconds=${RENDER_JOB_LEASE_SECONDS:-300}
    137      - Render__PollSeconds=${RENDER_POLL_SECONDS:-5}
    138      - Render__MaxPendingJobsPerUser=${RENDER_MAX_PENDING_JOBS_PER_USER:-100}
    139      - Render__MaxBodyMb=${RENDER_MAX_BODY_MB:-30}
    140      - Render__MaxTableRows=${RENDER_MAX_TABLE_ROWS:-50000}
    141      - Render__MaxItemsPerField=${RENDER_MAX_ITEMS_PER_FIELD:-1000}
    142      - Transfer__MaxZipMb=${PROJECT_IMPORT_MAX_MB:-200}
    143      - Webhook__SigningSecret=${WEBHOOK_SIGNING_SECRET:-}
    144      - Gotenberg__BaseUrl=http://qorstack-gotenberg:3000
    145      - Fonts__RendererCachePath=/renderer-fonts   # read-only view of the font-syncer volume → GET /fonts/status
    146      - Jwt__Key=${JWT_KEY}
    147      - Jwt__Issuer=qorstack-report
    148      - Jwt__Audience=qorstack-report
    149      - Encryption__Key=${ENCRYPTION_KEY}
    150      - Encryption__IV=${ENCRYPTION_IV}
    151      # Only for browser apps on OTHER origins that call the API directly (the built-in UI needs nothing).
    152      - Cors__AllowedOriginsString=${CORS_ORIGINS:-}
    153      - Auth__CookieSecure=${AUTH_COOKIE_SECURE:-}
    154      # Optional social sign-in (leave empty to use username/password only)
    155      - Google__ClientId=${GOOGLE_CLIENT_ID:-}
    156      - GitHub__ClientId=${GITHUB_CLIENT_ID:-}
    157      - GitHub__ClientSecret=${GITHUB_CLIENT_SECRET:-}
    158      # --- Pro license (optional) ---
    159      # Have a Pro license? You received a file named like:  license-you_at_example_com.json
    160      # 1) Put that file next to this docker-compose.yml (keep its original name).
    161      # 2) Uncomment the line below AND the license line under `volumes:` further down (put your
    162      #    file name on its left), then run: docker compose up -d   (`restart` does not apply changes)
    163      #    left  = your downloaded file   |   right = /app/license.json (fixed path the backend reads — don't change)
    164      # - Pro__LicenseFile=/app/license.json
    165    depends_on:
    166      postgres:
    167        condition: service_healthy
    168      rustfs:
    169        condition: service_healthy
    170        required: false   # disabled by the minio / external-s3 override files
    171    volumes:
    172      - fonts_cache:/renderer-fonts:ro
    173      # Pro license (see step 2 above):
    174      # - ./license-you_at_example_com.json:/app/license.json:ro
    175    networks:
    176      - qorstack-network
    177
    178volumes:
    179  postgres_data:
    180  rustfs_data:
    181  fonts_cache:
    182
    183networks:
    184  qorstack-network:
    185    driver: bridge
    186
    .env.example
    Download
    1# =============================================================================
    2# Qorstack Report — self-host configuration
    3#
    4# Quick start:
    5#   bash init.sh            # copies this file to .env and fills every change-me-* with random secrets
    6#   docker compose up -d    # then open http://localhost:8080 (web UI; the API is under /api)
    7#
    8# Every value below works as-is on one machine (localhost). Only the lines marked
    9# "PRODUCTION" need changing when you put the stack behind a real domain.
    10# =============================================================================
    11
    12
    13# ── Admin account ────────────────────────────────────────────────────────────
    14# The system admin is re-synced from these values on EVERY backend start.
    15# To rotate the password: change it here, then `docker compose up -d` again.
    16# ADMIN_EMAIL may be a plain username (e.g. "admin") or an e-mail address.
    17ADMIN_EMAIL=admin
    18ADMIN_PASSWORD=admin
    19
    20
    21# ── PostgreSQL ───────────────────────────────────────────────────────────────
    22# Defaults point at the bundled `postgres` container. To use an external database,
    23# set DB_HOST/DB_PORT to it and remove the postgres service from docker-compose.yml.
    24DB_HOST=qorstack-postgres
    25DB_PORT=5432
    26DB_NAME=qorstack_report
    27DB_USER=postgres
    28# Password for the bundled container AND the backend connection (generated by init.sh).
    29DB_PASSWORD=change-me-db-password
    30
    31
    32# ── File storage (S3) ────────────────────────────────────────────────────────
    33# Storage server: RustFS (Apache-2.0) is bundled and starts by default — nothing to set.
    34# To use something else, uncomment ONE line (Docker Compose reads COMPOSE_FILE from this file):
    35#   bundled MinIO instead of RustFS (v1 installs that keep their MinIO data):
    36# COMPOSE_FILE=docker-compose.yml:docker-compose.minio.yml
    37#   no bundled server — external S3 (AWS S3, Cloudflare R2, your own MinIO/RustFS); set STORAGE_* below:
    38# COMPOSE_FILE=docker-compose.yml:docker-compose.external-s3.yml
    39# Keeps ":" as the COMPOSE_FILE separator on every OS (Windows defaults to ";").
    40COMPOSE_PATH_SEPARATOR=:
    41# Address the backend + font-syncer use INSIDE the Docker network (host:port, no scheme).
    42# "qorstack-storage" is the network alias of the bundled server (RustFS or MinIO).
    43STORAGE_ENDPOINT=qorstack-storage:9000
    44# true only when STORAGE_ENDPOINT speaks HTTPS (e.g. AWS S3: s3.eu-central-1.amazonaws.com).
    45STORAGE_USE_SSL=false
    46# Region — most self-hosted servers accept the default. AWS S3: your bucket's region.
    47STORAGE_REGION=us-east-1
    48# Credentials. For the bundled server these BECOME its root user/password (first start only).
    49# The access key must not contain "/". STORAGE_SECRET_KEY is generated by init.sh.
    50STORAGE_ACCESS_KEY=qorstack
    51STORAGE_SECRET_KEY=change-me-storage-secret
    52# The ONE bucket that holds everything. Created automatically on start, with folders:
    53#   template/          uploaded Word / Excel templates
    54#   generated-report/  generated files, sandbox assets, temporary downloads (deleted after 1 day)
    55#   fonts/             fonts uploaded from the Fonts page (mirrored to Gotenberg by font-syncer)
    56STORAGE_BUCKET=qorstack-report
    57# Storage does NOT need a public address: every download link points at the API
    58# ({PUBLIC_URL}/api/files/...), which streams the file. Port 9000 is not published.
    59# Already serve storage on its own public address (e.g. https://storage.example.com)? Set it here and
    60# download links become presigned URLs for that address (browsers fetch from storage directly, as in v1).
    61# STORAGE_PUBLIC_ENDPOINT=
    62#
    63# Upgrading from v1? Your old .env has MINIO_* names — they still work (STORAGE_* wins when
    64# both are set). Before starting v2, copy the old data once: `bash migrate-storage.sh`
    65# (see README › Upgrading to v2).
    66
    67
    68# ── Render request limits ────────────────────────────────────────────────────
    69# Protect the server from oversized render requests. 0 = unlimited.
    70# A request over a limit gets 400 naming the field and the setting to raise.
    71# Max request body in MB (keep it below your reverse proxy limit, e.g. nginx client_max_body_size).
    72RENDER_MAX_BODY_MB=30
    73# Max rows in one table.
    74RENDER_MAX_TABLE_ROWS=50000
    75# Max items in each of replace / image / qrcode / barcode.
    76RENDER_MAX_ITEMS_PER_FIELD=1000
    77
    78# ── Project export / import (Pro) ────────────────────────────────────────────
    79# Largest project ZIP accepted by Export & import → Import (MB). Keep it below your reverse proxy limit.
    80PROJECT_IMPORT_MAX_MB=200
    81
    82
    83# ── Async renders + webhooks (Pro) ───────────────────────────────────────────
    84# "async": true queues a render and returns 202 with a job id; poll GET /render/jobs/{id}.
    85# Jobs are stored in PostgreSQL (report_jobs): they survive restarts, and several backend
    86# replicas can share the queue. A job whose worker dies is picked up again when its lease ends;
    87# network/storage/Gotenberg errors are retried (after 10 s, then 60 s, 3 tries in total).
    88# Parallel async renders per backend (each uses CPU + one Gotenberg conversion).
    89RENDER_ASYNC_WORKERS=2
    90# How long a worker may hold a job before another worker can take it over (renewed while rendering).
    91RENDER_JOB_LEASE_SECONDS=300
    92# Workers are woken instantly (LISTEN/NOTIFY); this poll is the fallback.
    93RENDER_POLL_SECONDS=5
    94# Unfinished async renders allowed per user; more → 429 Too Many Requests.
    95RENDER_MAX_PENDING_JOBS_PER_USER=100
    96# Webhooks are tried 3 times (now, +10 s, +60 s); delivery state is stored, so restarts only delay them.
    97# Secret for the X-Qorstack-Signature header on webhook POSTs (sha256=HMAC of the body).
    98# Generated by init.sh; empty = unsigned webhooks.  Generate: openssl rand -hex 32
    99WEBHOOK_SIGNING_SECRET=change-me-webhook-signing-secret
    100
    101# ── Security keys (generated by init.sh — never reuse the examples) ──────────
    102# JWT signing key, at least 32 characters.  Generate: openssl rand -hex 24
    103JWT_KEY=change-me-jwt-signing-key-at-least-32-chars
    104# AES key for stored secrets, EXACTLY 32 characters.  Generate: openssl rand -hex 16
    105# Do not change after first start — data encrypted with the old key can no longer be read.
    106ENCRYPTION_KEY=change-me-encryption-key-32char!
    107# AES IV, EXACTLY 16 characters.  Generate: openssl rand -hex 8
    108ENCRYPTION_IV=change-me-iv-16!
    109
    110
    111# ── Web app + API (one container, one port) ──────────────────────────────────
    112# Host port. Open http://localhost:8080 for the web UI; the API is http://localhost:8080/api
    113APP_PORT=8080
    114# The address people open (put in download links: {PUBLIC_URL}/api/files/...).
    115# PRODUCTION: e.g. https://report.example.com
    116PUBLIC_URL=http://localhost:8080
    117# Only needed for browser apps on OTHER origins that call the API directly. Comma-separated.
    118# The built-in web UI is on the same origin and needs nothing here.
    119CORS_ORIGINS=
    120# Sign-in cookies follow the address in the browser: Secure on https://, plain on http:// (a NodePort,
    121# an intranet IP), so both work with no setting. Set true/false only to force one.
    122# AUTH_COOKIE_SECURE=
    123
    124# ── Social sign-in (optional) ────────────────────────────────────────────────
    125# Leave empty to sign in with username/password only.
    126# Google: OAuth client id (Google Cloud Console → Credentials), authorised origin = PUBLIC_URL.
    127GOOGLE_CLIENT_ID=
    128# GitHub: OAuth app (github.com/settings/developers), callback = {PUBLIC_URL}/auth/github/callback
    129GITHUB_CLIENT_ID=
    130GITHUB_CLIENT_SECRET=
    131
    132
    133# ── Image versions ───────────────────────────────────────────────────────────
    134# Leave unset to track :latest, or pin a release for reproducible deploys.
    135# REPORT_TAG=2.4.0
    136
    curl -fsSLO https://report.qorstack.com/selfhost/init.sh
  2. Step02

    Create .env with random secrets

    init.sh copies .env.example to .env, replaces every change-me value with a random secret and prints the admin password — save it. No bash? Copy the file to .env and replace the change-me values yourself; every variable is explained in comments.
    bash init.sh
  3. Step03

    Start the stack

    Docker pulls the images, creates volumes, and brings up everything in order.
    docker compose up -d

    First run takes ~1–2 minutes while images download.

  4. Step04

    Open the web UI and log in

    Log in as admin with the password init.sh printed. The login comes from ADMIN_EMAIL / ADMIN_PASSWORD in .env and is re-applied on every restart. To change it, edit .env and run docker compose up -d again.

    Web UI

    http://localhost:8080

    Login: admin / the password init.sh printed (ADMIN_PASSWORD in .env)

    API

    http://localhost:8080/api

    Login: X-API-Key header

Or let an AI coding agent do it

Paste this prompt into Claude Code, Cursor or a similar agent on the machine that should run the stack.

agent prompt
Deploy Qorstack Report on this machine with Docker and confirm it's running. Use the prebuilt images — do NOT build images, edit the Dockerfile, or change project files (the only allowed edit is remapping a clashing port, below). If anything else is blocked, STOP and report it; don't work around it.

  git clone https://github.com/qorstack/qorstack-report.git
  cd qorstack-report/selfhost
  bash init.sh           # writes .env with random secrets and prints the admin password — keep that line

Before starting, make sure host port 8080 is free — it is the only port the stack publishes (web UI + API in one container; PostgreSQL, storage and Gotenberg stay inside the Docker network). If 8080 is in use, set APP_PORT (e.g. 8081) and PUBLIC_URL=http://localhost:8081 in .env and say what you changed.

  docker compose up -d   # first run pulls images (~2 min); multi-arch, Apple Silicon OK

Then verify and report — that's the whole job: still in qorstack-report/selfhost, run "bash verify.sh" (it reads APP_PORT from .env; expect "All checks passed") and report the web UI URL, the admin login printed by init.sh, the verify output, and any ports you remapped.

Optional, in the WEB UI (not via scripts): upload examples/invoice/invoice.docx, create an API key (Settings -> API Keys), and render examples/invoice/data.json (see examples/invoice/README.md).

03 — What's inside

Five services. One compose file.

RustFS — default

Bundled S3 storage (Apache-2.0). Starts with plain docker compose up -d.

MinIO — keep v1 data

Uncomment in .env to run the bundled MinIO instead.

COMPOSE_FILE=docker-compose.yml:docker-compose.minio.yml

External S3

AWS S3, Cloudflare R2 or your own server — no bundled storage. Set STORAGE_* too.

COMPOSE_FILE=docker-compose.yml:docker-compose.external-s3.yml

App

Web UI + .NET API in one container (port 8080)

PostgreSQL

Application database

RustFS (or MinIO)

S3 storage — one bucket for templates, reports & fonts

Font Syncer

Mirrors fonts into the render volume

Gotenberg

DOCX → PDF conversion (+ Excel previews)

04 — Going to production

Three things before going live.

A

Change these secrets

The defaults work on localhost but must be rotated before you expose the instance.

VariablePurpose
ADMIN_EMAILAdmin login (re-applied on every restart)
ADMIN_PASSWORDAdmin password (re-applied on every restart)
DB_PASSWORDPostgreSQL password
STORAGE_ACCESS_KEYS3 storage access key (RustFS/MinIO root user)
STORAGE_SECRET_KEYS3 storage secret key
JWT_KEYJWT signing key — at least 32 characters
ENCRYPTION_KEYAES key — exactly 32 characters
ENCRYPTION_IVAES IV — exactly 16 characters

Generate strong secrets

1openssl rand -hex 32   # JWT_KEY
2openssl rand -hex 16   # ENCRYPTION_KEY  (32 hex chars)
3openssl rand -hex 8    # ENCRYPTION_IV   (16 hex chars)

The admin login comes from ADMIN_EMAIL / ADMIN_PASSWORD and is re-applied on every restart, so a password changed in the app is reset on the next start. To rotate it, change both in .env and run docker compose up -d. Leave them blank after first login if you prefer to manage the password in the app.

B

Reach it from another device

When opening the UI from another device, localhost points to that device, not the server. Set PUBLIC_URL in .env to the address people open — the web UI and the API share it:

VPN / LAN

PUBLIC_URL=http://192.168.1.10:8080

Domain + HTTPS

PUBLIC_URL=https://report.your-domain.com
C

Use external PostgreSQL or S3

Already running your own database or object store? Point the stack at it and disable the bundled service.

Your own PostgreSQL

DB_HOST=your-postgres-host
DB_PORT=5432
DB_NAME=qorstack_report
DB_USER=postgres
DB_PASSWORD=your-db-password

Then disable the postgres service in docker-compose.yml.

S3-compatible store

COMPOSE_FILE=docker-compose.yml:docker-compose.external-s3.yml
STORAGE_ENDPOINT=s3.eu-central-1.amazonaws.com
STORAGE_USE_SSL=true
STORAGE_REGION=eu-central-1
STORAGE_ACCESS_KEY=...
STORAGE_SECRET_KEY=...
STORAGE_BUCKET=qorstack-report

The external-s3 override skips the bundled RustFS (keep COMPOSE_PATH_SEPARATOR=: from .env.example). Works with AWS S3, Cloudflare R2 or your own MinIO/RustFS. Everything goes in the one bucket under template/, generated-report/ and fonts/. Storage needs no public address — downloads go through the API.

05 — Activate Pro

Already have a license?

Drop your license file next to docker-compose.yml and recreate the app container. (Feature comparison is at the bottom of this page.)

With a license file

  1. 01Place your license file (named like license-you_at_example_com.json) next to docker-compose.yml — keep its original name.
  2. 02Under the app service, uncomment Pro__LicenseFile and the license line in volumes:, with your file name on the left (see diff below).
  3. 03Run docker compose up -d — it recreates app with the new settings; docker compose restart would not apply them.
BeforeFree — license lines commented out
app:
  environment:
    # - Pro__LicenseFile=/app/license.json
  volumes:
    - fonts_cache:/renderer-fonts:ro
    # - ./license-you_at_example_com.json:/app/license.json:ro
AfterPro — both license lines uncommented
app:
  environment:
    - Pro__LicenseFile=/app/license.json
  volumes:
    - fonts_cache:/renderer-fonts:ro
    - ./license-you_at_example_com.json:/app/license.json:ro

License is validated offline — no call to Qorstack.

Check active flags: curl http://localhost:8080/api/features

06 — Day-2 ops

Update, stop, wipe.

Update

docker compose pull
docker compose up -d

Stop (keep data)

docker compose down

Stop + wipe data

docker compose down -v

Upgrading from v1 (buckets templates / reports / fonts)? Before starting v2, run bash migrate-storage.sh keep-minio (stay on MinIO) or to-rustfs (move to RustFS) once. It copies the old buckets into one bucket (default qorstack-report) with rclone and leaves them untouched. Your v1 MINIO_* settings keep working. v2 also accepts render requests as keyed arrays only — see the changelog on GitHub.

07 — Troubleshooting

Something not working?

Check the logs

docker compose ps
docker compose logs app
docker compose logs gotenberg
docker compose logs font-syncer
SymptomCheck
Login fails on first runADMIN_EMAIL, ADMIN_PASSWORD, docker compose logs app
Page loads but sign-in or data failsdocker compose logs app, /api/health
Previews / downloads failPUBLIC_URL reachable from the browser, docker compose logs rustfs (or minio)
PDF conversion failsgotenberg logs
Custom fonts missingfont-syncer logs
View on GitHubReport an issue

Free vs Pro

Run the document API on your own infrastructure. Pro adds PDF security, async renders with webhooks, ZIP output, project export & import and more template versions — a one-time licence, validated offline.

Feature
FreeMIT
ProLicense
PDF, DOCX & Excel generation
REST API, SDKs & MCP server
Unlimited projects, templates & documents
Project members & API keys
Google Fonts + custom font upload
QR codes & barcodes
Auto-detected template variables
Live preview while you edit
Generation history
Template versions kept per template
1
10
PDF password protection
PDF watermarks
Async renders with signed webhooks
ZIP output
Project export & import
License
MIT
Commercial
QORSTACKREPORT · MIT CORE
DocsSelf-hostGitHubPricing

© 2026 Qorstack Report. All rights reserved.

Templates in · PDF & Excel out