Appearance
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.
| Path | What |
|---|---|
/app/reportd, /app/renderd | The binaries (/app is on PATH) |
/app/studio, /app/viewer | The designer and viewer apps |
/app/fonts | The bundled fonts |
/data | The volume: the stores, the git repository, auth.json, keys, the audit log, backups |
/config/config.yaml | Optional, mounted |
/run/secrets/ | Where secret:file:NAME references are read |
- The user is uid and gid 65532 (
nonroot)./databelongs 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
HEALTHCHECKisreportd healthcheck, which calls/livezon reportd's own address and exits 0 or 1.reportd healthcheck --readycalls/readyzinstead. renderd alone hasrenderd --healthcheck, which counts a busy renderd (503) as up. - Platforms:
linux/amd64andlinux/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 imagebuilds 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-tokenreportd 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:publicURLisREPORTD_PUBLICURL,templateStore.driverisREPORTD_TEMPLATESTORE_DRIVER,auth.sessions.idleTimeoutisREPORTD_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_TOKENandREPORTD_API_KEY(themcpandtestcommands'), and any variable asecret:env:reference names, which is that secret, not a setting: withadmin.password: secret:env:REPORTD_ADMIN_PASSWORDin the file,REPORTD_ADMIN_PASSWORDis the password. - Secrets stay
secret:references:REPORTD_STATE_POSTGRES_URL=secret:file:postgres-urlreads/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_CONFIGorconfig.yamlis skipped when it isn't there; one named by--configmust 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 /:
| Variable | Value |
|---|---|
REPORTD_CONFIG | /config/config.yaml (optional) |
REPORTD_LISTEN | 0.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_DIR | env,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 setupCodeand open http://localhost:43123/setup with the code.
| Files | What runs |
|---|---|
docker-compose.yml | reportd with renderd inside it, a named volume for /data, the health check, restart: unless-stopped |
… -f postgres.yml | Postgres 17, and reportd's users, keys, roles and sessions in it (state.driver: postgres, state.md) |
… -f split.yml | renderd 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
postgresandsplitfiles runreportd compose-secretsonce in a short-lived container, which makespostgres-password,postgres-url,mfa-keyandlink-key(the keys every node must share on Postgres, state.md) andrenderd-tokenindeploy/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
scalebefore 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 indeploy/compose/secrets/licenceand addREPORTD_LICENCE: secret:file:licenceto reportd'senvironment. - Scaling renderd. reportd looks the name
renderdup 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/healthanswers. - Other settings go in reportd's
environmentindocker-compose.yml, or in a config file mounted at/config/config.yamlfor 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 withdocker 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.urlis one, as before.renderer.urlslists several.renderer.discover: dnslooksrenderer.discoverName(host:port) up every 5 seconds and uses every address behind it: a Kubernetes headless Service or a compose service name.renderer.commandstarts one renderd inside and can't go withurlsordiscover. - 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
/healthevery 5 seconds until it answers. When every instance is out or busy, the caller gets503 saturatedwithRetry-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(orRENDERD_TOKEN_FILE) refuses every request withoutAuthorization: Bearer <token>with401 unauthorized, except/health; the comparison takes the same time wherever the tokens differ. renderd inside reportd's container listens on loopback and needs none. /readyzis ready while at least one instance answers, and onstate.driver: postgreswhile 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.