Skip to main content

Deploying with Docker Compose

deploy/compose/compose.yaml runs the Gateway and the Worker from the outprint image. The worker service is the shipped Worker baseline of ADR 0001.

Run it​

export OUTLINE_URL=https://wiki.example.test # your Outline
# optional: a token shared by Gateway and Worker
export OUTPRINT_WORKER_TOKEN="$(openssl rand -hex 32)"

docker compose -f deploy/compose/compose.yaml up --build

The Gateway listens on port 8080 (OUTPRINT_PORT changes the published port). Download a Document as PDF while signed in to Outline:

GET http://localhost:8080/render/<documentId>

The browser must send the user's Outline session cookie, so in a real deployment the Gateway is served from the same site as Outline: the injector routes /pdf to it (Injector and download button). Check the file with docker compose -f deploy/compose/compose.yaml config.

./templates and ./configurations of the repository are mounted read-only into both services at the paths of the shipped service.yaml. Replace the sources to install your own packages; the service configuration inside the image lists the allowed Bindings and the default Binding.

For a local stack with Outline, a login and seeded content, see Development stack and end-to-end tests; it layers its own file over this one.

The injector​

The injector service is built from the injector target of the Dockerfile (task image-injector): a distroless, non-root image without TeX. It is not part of the Worker baseline and the sandbox tests do not read it. Point it at Outline and publish it instead of Outline:

export OUTPRINT_INJECTOR_UPSTREAM=http://outline:3000 # Outline, as the injector reaches it
docker compose -f deploy/compose/compose.yaml up --build
# http://localhost:8090/ is Outline with the button; /pdf/* is the Gateway

In production publish the injector only, not the Gateway's own port; the Gateway sends no CORS headers and must stay same-origin.

The Worker baseline​

SettingWhy
user: "10001:10001"fixed non-root user; the Worker also refuses to start as UID 0
security_opt: no-new-privileges:trueno privilege gain through setuid binaries
no seccomp entryDocker's default profile (RuntimeDefault) applies; never unconfined
cap_drop: [ALL]the Worker needs no capability
read_only: truethe root file system cannot be written
tmpfs: /work, mode 0700, uid=10001, 256 MiBthe job directories (OUTPRINT_WORK_DIR); gone when the container stops
tmpfs: /tmpscratch space for tools
networks: [worker-net], internal: trueno route out: no internet, no Outline, no host
no portsthe Worker is reachable from the Gateway only
no secretsthe only environment variable that looks like one is the optional OUTPRINT_WORKER_TOKEN
package mounts read_only: truea package cannot be changed from inside
mem_limit: 1g, cpus: 1, pids_limit: 128a resource bomb ends at the container
restart: alwaysthe Worker exits after a timeout or kill; this gives the next job a clean process

The baseline is enforced by the build: internal/sandbox reads this file, fails if any row above is weakened or if the service uses a key it does not know, and the security suite runs the Worker with exactly these settings (Security).

At start the Worker logs its posture (worker security posture); anything weaker than this table is logged as worker posture is weaker than the shipped baseline.

The Gateway is also non-root with all capabilities dropped and a read-only root. It is on worker-net (to reach the Worker) and on frontend (to publish its port and reach Outline). It needs OUTLINE_URL and refuses to start without it.

Scaling​

Throughput scales by Worker replicas, not by parallel jobs in one Worker. One slow render delays others; a busy Worker answers 429 with Retry-After through the Gateway. The Gateway's admission control (slots, a queue, a per-user limit) sits in front of the Workers, see Limits and admission control; raise its slots together with the number of Worker replicas.