Skip to content

Docker and compose ​

Reportforge ships as one container image with two modes: reportd with renderd inside it (the default), or renderd alone. deploy/compose/ runs it on one machine; helm.md runs it on Kubernetes. The first start is the setup wizard (setup.md).

The image ​

deploy/Dockerfile builds it in stages and ends on gcr.io/distroless/cc-debian12:nonroot: no shell, no package manager, glibc for renderd.

PathWhat
/app/reportd, /app/renderdThe binaries (/app is on PATH)
/app/studio, /app/viewerThe designer and viewer apps
/app/fontsThe bundled fonts
/dataThe volume: the stores, the git repository, auth.json, keys, the audit log, backups
/config/config.yamlOptional, mounted
/run/secrets/Where secret:file:NAME references are read
  • The user is uid and gid 65532 (nonroot). /data belongs to it in the image, so a new named volume does too; a bind mount must be writable by 65532.
  • Ports: 43123 for reportd, the only port the image exposes. renderd alone listens on 43124, and only with --token-file: without a token it refuses any address but loopback (security.md).
  • Health: the image's HEALTHCHECK is reportd healthcheck, which calls /livez on reportd's own address and exits 0 or 1. reportd healthcheck --ready calls /readyz instead. renderd alone has renderd --healthcheck, which counts a busy renderd (503) as up.
  • Platforms: linux/amd64 and linux/arm64. Every build stage runs on the build machine's platform: renderd is cross-compiled for the target, reportd too (CGO off), and only the last stage is the target's, so no emulation is needed.
  • Labels: the OCI labels for the version, commit, source, vendor and licence.
  • Not in it: veraPDF. Publishing (a registry, tags, signatures) belongs to release engineering.

Building it ​

bash
cargo xtask image

builds this machine's platform and loads it into docker as report-forge:dev. --licencetest builds report-forge:licencetest instead, a test image whose reportd trusts the tests' licence key, for trying licences; never ship it. --tag names it otherwise; --platform linux/arm64 builds another platform; --all builds both platforms together, as CI does: with Docker's containerd image store (Docker Desktop's default) both are kept under the tag, otherwise only in the build cache. The build downloads the base images (pinned by digest), the Rust, Go and npm dependencies, and wasm-bindgen's CLI at the version Cargo.lock pins. deploy/smoke-test.sh [image] runs CI's smoke test against an image: setup through the API, /readyz, a PDF render, a restart on the same volume, and renderd alone refusing a request without its token.

Its modes ​

bash
# reportd, with renderd inside it on loopback
docker run -d -p 43123:43123 -v reports:/data report-forge:dev

# renderd alone, for reportd elsewhere to spread renders over
docker run -d -p 43124:43124 -v ./renderd-token:/run/secrets/renderd-token:ro \
  report-forge:dev renderd --listen 0.0.0.0:43124 --token-file /run/secrets/renderd-token

reportd uses renderd elsewhere when REPORTD_RENDERER_COMMAND is set to nothing and REPORTD_RENDERER_URL, REPORTD_RENDERER_URLS or REPORTD_RENDERER_DISCOVER says where (Spreading renders).

Settings from the environment ​

Every setting can come from a REPORTD_ variable, so a container needs no config file. The configuration reference lists each setting with its variable.

  • Names. REPORTD_ and the setting's YAML path in capitals, each level joined by _, ignoring the camel case inside a level: publicURL is REPORTD_PUBLICURL, templateStore.driver is REPORTD_TEMPLATESTORE_DRIVER, auth.sessions.idleTimeout is REPORTD_AUTH_SESSIONS_IDLETIMEOUT.
  • Values are parsed as the setting's type: true/false, whole numbers, Go durations (2h), and comma-separated lists (REPORTD_SOURCECONTROL_ENVIRONMENTS=dev,test,prod). A variable set to nothing sets the setting to nothing: REPORTD_RENDERER_COMMAND= turns off the renderd inside.
  • Lists of objects and maps stay in the file: auth.oidc.providers, auth.saml.providers, connections, jobs.webhooks.endpoints, limits.rate, extensions, assets.remote. A variable naming one, or anything inside one, is refused at start.
  • Unknown variables are refused at start, so a typo fails loudly. Allowed besides the settings: REPORTD_CONFIG, REPORTD_TOKEN and REPORTD_API_KEY (the mcp and test commands'), and any variable a secret:env: reference names, which is that secret, not a setting: with admin.password: secret:env:REPORTD_ADMIN_PASSWORD in the file, REPORTD_ADMIN_PASSWORD is the password.
  • Secrets stay secret: references: REPORTD_STATE_POSTGRES_URL=secret:file:postgres-url reads /run/secrets/postgres-url.
  • Order: the defaults, then the config file, then dataDir/settings.yaml (what setup wrote), then the environment. The config file is optional: $REPORTD_CONFIG or config.yaml is skipped when it isn't there; one named by --config must be.

reportd config show prints the settings reportd runs on, as YAML, with secrets masked (a literal where a secret goes, and a password in a URL); a secret: reference shows as it is. --origin marks each value with where it came from:

text
$ docker exec rf reportd config show --origin
# config file: none
# settings: /data/settings.yaml
listen: 0.0.0.0:43123 # env REPORTD_LISTEN
publicURL: https://reports.example.com # settings
dataDir: /data # env REPORTD_DATADIR
...

The image sets these, over reportd's workstation defaults. The store paths keep their defaults (data/templates, data/reports, data/repo), which land in /data since the image's working folder is /:

VariableValue
REPORTD_CONFIG/config/config.yaml (optional)
REPORTD_LISTEN0.0.0.0:43123
REPORTD_DATADIR/data
REPORTD_RENDERER_COMMAND/app/renderd
REPORTD_STUDIO_DIR, REPORTD_VIEWER_DIR/app/studio, /app/viewer
REPORTD_SECRETS_PROVIDERS, REPORTD_SECRETS_DIRenv,file, /run/secrets

The environment wins over the file, so a setting the image sets can be changed only by a variable.

The image ships no example reports. The studio lists the example gallery from the public repository, and copies in the one someone picks (starting from a template). On a machine without internet access, set REPORTD_EXAMPLES_GALLERY to a folder holding a downloaded gallery, or to off.

reportd checks once a day for a newer release, and tells administrators in the studio (updates.md). Without internet access the check fails quietly; REPORTD_UPDATES_CHECK=false turns it off.

Compose ​

deploy/compose/ runs Reportforge on one machine. Copy .env.example to .env and edit it: the image, the port, and REPORTD_PUBLICURL if you know it. Then, from deploy/compose/:

bash
docker compose up -d
docker compose logs reportd | grep setupCode

and open http://localhost:43123/setup with the code.

FilesWhat runs
docker-compose.ymlreportd with renderd inside it, a named volume for /data, the health check, restart: unless-stopped
… -f postgres.ymlPostgres 17, and reportd's users, keys, roles and sessions in it (state.driver: postgres, state.md)
… -f split.ymlrenderd as its own service, which reportd spreads renders over: docker compose -f docker-compose.yml -f split.yml up -d --scale renderd=3

The two extra files are the plan's "profiles". Compose's own profiles can add services but can't change reportd's environment, which both need, so they are override files; they can go together.

  • Secrets. The postgres and split files run reportd compose-secrets once in a short-lived container, which makes postgres-password, postgres-url, mfa-key and link-key (the keys every node must share on Postgres, state.md) and renderd-token in deploy/compose/secrets/ and keeps any that are there. git ignores that folder. reportd, Postgres and renderd read them from /run/secrets/.
  • A licence. Postgres state and renderd as its own service are "scale" features (licence.md). A new install with either still starts, and the setup wizard asks for a licence with scale before it will finish. An install that already has users opens only the Licence dialog until one is installed. An install from before licences has 60 days first. To give the licence before the first start instead, put it in deploy/compose/secrets/licence and add REPORTD_LICENCE: secret:file:licence to reportd's environment.
  • Scaling renderd. reportd looks the name renderd up every 5 seconds and uses every container behind it, so scaling up or down takes effect within seconds. A stopped instance is left out after two failures and taken back when its /health answers.
  • Other settings go in reportd's environment in docker-compose.yml, or in a config file mounted at /config/config.yaml for lists of objects such as sign-in providers.
  • Backups: docker compose exec reportd reportd backup --out /data/backups/manual.tar.gz (backup.md), then copy it off the volume with docker compose cp.

Spreading renders ​

reportd spreads renders over several renderd instances itself; nothing runs in front of them.

yaml
renderer:
  command: ""                                    # no renderd inside
  urls: [http://render-1:43124, http://render-2:43124]
  # or: discover: dns, discoverName: renderd-headless.reports.svc:43124
  token: secret:file:renderd-token               # what renderd --token-file requires
  • Where the instances are: renderer.url is one, as before. renderer.urls lists several. renderer.discover: dns looks renderer.discoverName (host:port) up every 5 seconds and uses every address behind it: a Kubernetes headless Service or a compose service name. renderer.command starts one renderd inside and can't go with urls or discover.
  • Which one a call goes to: the instance with the fewest calls in flight from this reportd; ties go in turn. The asset uploads that follow a 409 missing-assets, and the call again, stay on the instance that asked, since each renderd has its own asset cache. A font is asked of each instance until one has it.
  • Retrying: a 503 saturated, a refused connection or one closed before any answer goes once more to another instance. Nothing else is tried again, nor a call that timed out, since it may still be running.
  • Instances left out: one that fails twice in a row is left out and asked for /health every 5 seconds until it answers. When every instance is out or busy, the caller gets 503 saturated with Retry-After, and reportd logs a warning at most once a minute: the sign that more instances are needed.
  • The token is the same for every instance. renderd started with --token-file (or RENDERD_TOKEN_FILE) refuses every request without Authorization: Bearer <token> with 401 unauthorized, except /health; the comparison takes the same time wherever the tokens differ. renderd inside reportd's container listens on loopback and needs none.
  • /readyz is ready while at least one instance answers, and on state.driver: postgres while Postgres answers.

renderd's /health reports its pool, for dashboards and a later autoscaler on the queue:

json
{"status": "ok", "version": "0.9.0", "workers": 4, "running": 1, "queued": 0, "queueLimit": 16}

workers follows the CPUs renderd may use (a container's CPU limit), and queueLimit is four times that; past it renderd answers 503 saturated.