Skip to content

Install with Docker Compose

Yggdrasil runs on one machine with Docker Compose. Each installation belongs to one institution and holds only that institution’s projects; every installation runs the same release, with no closed features.

  • A machine (x86-64 or 64-bit ARM: the images are built for linux/amd64 and linux/arm64) with Docker Engine and the Compose plugin. Indicative minimum: 4 vCPU, 8 GB RAM, plus disk for the encrypted data.
  • Two host names pointing at it, three with the landing site: the web app (for example ygg.example.org), the storage endpoint browsers upload to, on a domain of its own (ygg-data.example.net), and optionally the landing site and docs. Passkeys need HTTPS on a real host name.
  • Ports 80 and 443 reachable from the internet, so Caddy can obtain certificates from Let’s Encrypt. If another reverse proxy on the machine already owns them, Yggdrasil can sit behind it.
  • An SMTP server that sends from your domain. Sign-in codes, invitations and replies to project requests go by email.

The example.org names on this page are placeholders: use your own.

deploy/compose.yml starts these containers. Only Caddy publishes ports.

Service Role
caddy TLS for every host name, certificates included; serves the web app and the landing site; forwards /v1 and export links (/x/…) to the API and the storage host to RustFS (or to Garage, or to another S3 service). Sets the security headers below.
api FastAPI server: metadata, wrapped keys, presigned storage URLs, sign-in. Never sees plaintext data.
worker Background jobs: emails, expired processor leases, session clean-up, storage of deleted projects.
migrate Runs on every start: database migrations, then the bucket (YGG_S3_BUCKET) if it is missing, then its CORS and lifecycle rules. It never creates the bucket while the database lists stored files: storage that lacks them is the wrong one, so it stops instead (see Upgrades). The encrypted files live under p/, export archives under exports/, where every object expires after at most 34 days, a backstop behind the deletion at each export’s expiry. Then it exits.
postgres PostgreSQL 17: projects, members, encrypted index rows and the audit log.
rustfs RustFS, single node: the encrypted files, and export archives until they expire. Its key is the API’s S3 key. Garage can take its place.
export-worker The hosting server’s export worker (see Export worker), which decrypts what project admins grant it and packages it into exports/. On by default; you can turn it off.

The database and storage sit on an internal network with no route out. Every container runs without Linux capabilities, cannot gain privileges, and has a read-only root filesystem; none runs as root (except Garage, if you choose it: its image has no other user), and Caddy binds ports 80 and 443 as an ordinary user. No container has a secret in its environment, where docker inspect would show it: secrets are files (see Configure). RustFS’s web console is off, and Caddy’s admin API is off. The API trusts X-Forwarded-For only from Caddy’s network, so rate limits and sign-in records see the address Caddy received; Client addresses explains how to make that the client’s.

Sign-in limits count requests per client: an IPv4 address, or an IPv6 client’s whole /64. They work as intended only when Caddy receives each client’s own address:

  • IPv4 clients on other hosts keep their address: Docker forwards them to Caddy with its NAT rules.

  • IPv6 clients keep theirs only if the compose network has IPv6. Without it, Docker’s userland proxy relays them to Caddy over IPv4, and every one of them arrives as the network’s gateway, sharing one limit. Start the stack with the IPv6 overlay (Docker 27 or later, IPv6 on the host; YGG_PROXY_SUBNET6 in deploy/.env sets its subnet):

    Terminal window
    docker compose -f deploy/compose.yml -f deploy/compose.ipv6.yml up -d --wait

    Or turn the userland proxy off ("userland-proxy": false in /etc/docker/daemon.json, then restart Docker). IPv6 clients then cannot reach an IPv4-only network at all, so publish an AAAA record only with the overlay.

  • Connections from the host itself, or from containers through the host’s address, always arrive as the gateway.

  • Behind a load balancer or another proxy, Caddy sees only the balancer. Caddy trusts no X-Forwarded-For it receives unless it comes from YGG_TRUSTED_PROXIES in deploy/.env: set it to the balancer’s addresses (or ranges, separated by spaces or commas), and make sure the balancer adds the address it received to the header rather than passing on a client’s own. A reverse proxy on the same machine is described in Behind an existing reverse proxy.

To check, sign in and open Devices: your session’s network should be yours, not the proxy network (172.30.36.0/24 unless YGG_PROXY_SUBNET says otherwise). Clients behind one NAT share an address whatever you do; requests for email codes are therefore also limited per address and network, under a higher ceiling per address, so one network cannot use up another’s.

Terminal window
git clone https://github.com/lorenzobrg/yggdrasil4 yggdrasil && cd yggdrasil
git checkout vX.Y.Z # the release you install

Point an A (and AAAA, if the host has IPv6) record for each host name at the machine. Caddy asks Let’s Encrypt for a certificate the first time each name is requested and renews it on its own; nothing else is needed for TLS.

For an instance on a private network without public DNS, use names under .internal: Caddy then issues certificates from its own certificate authority, whose root (/data/caddy/pki/authorities/local/root.crt in the caddy container) every member’s browser must trust.

Terminal window
deploy/init-env.sh # writes deploy/.env, and deploy/secrets/ with fresh random secrets

Then edit deploy/.env (its comments explain every line):

  • YGG_APP_URL: the web app, for example https://ygg.example.org. The API answers on the same host under /v1.
  • YGG_WEBAUTHN_RP_ID: the app’s host name, or a parent domain of it. Passkeys are bound to it: changing it later invalidates every passkey.
  • YGG_S3_URL: the storage host browsers reach, for example https://ygg-data.example.net. Put it on a separate registrable domain, never on YGG_WEBAUTHN_RP_ID or a name under it: a page served from the storage host could otherwise ask for your users’ passkeys and set cookies for the app. The API refuses to start with a storage host under the RP ID. Caddy serves every object on it as a download (an application/octet-stream attachment under a sandbox CSP).
  • YGG_SITE_URL: optional, the landing site and docs on their own host. With it set, the web app links the site’s terms of use (/terms/) and privacy notice (/privacy/) from its footer, the sign-in page and the request form; without it, the app shows neither link. Both pages name the operator from the build-time settings below; without them they show placeholders and a draft banner.
  • YGG_TERMS_URL, YGG_PRIVACY_URL: optional (https), where your institution publishes its own terms of use or privacy notice, for example a notice its data protection officer keeps with the institution’s other notices. Set, the app links there instead of the site’s page. An installation whose privacy notice differs from the shipped one in more than the YGG_PRIVACY_* settings below publishes its own this way rather than editing privacy.mdx.
  • YGG_OPERATOR, YGG_OPERATOR_ADDRESS, YGG_OPERATOR_EMAIL and YGG_OPERATOR_COURTS: your institution, which runs this installation for its own projects: its name, postal address, contact email, and the courts with jurisdiction over the terms of use (for example Modena, Italy). The landing site’s terms of use and privacy notice name it; the released images name no operator.
  • YGG_INSTITUTION_NAME: the institution that runs this installation, as the app names it, for example AImageLab, University of Modena and Reggio Emilia. The web app shows it in the header, on the sign-in page and in the About box, on the badge of the processors your catalog offers (“Offered by …”), and every email ends with it. Unset, the app names no institution. It is read by the API at start, so changing it there needs only a restart; the landing page names it too, and is built with it like the operator settings above. Whatever you set, the footer credits the software’s developer (“Developed by AImageLab”).
  • YGG_TERMS_DATE: the date (YYYY-MM-DD) your reviewed terms of use take effect. Unset, the terms carry a “DRAFT, pending review” banner; set, the build requires the four operator settings.
  • YGG_PRIVACY_DPO, YGG_PRIVACY_BASIS_RESEARCH, YGG_PRIVACY_BASIS_ACCOUNTS, YGG_PRIVACY_BASIS_REQUESTS, YGG_PRIVACY_BASIS_SECURITY, YGG_PRIVACY_ACCOUNT_RETENTION, YGG_PRIVACY_LOG_RETENTION, YGG_PRIVACY_BACKUP_RETENTION, YGG_PRIVACY_HOSTING, YGG_PRIVACY_EMAIL_PROVIDER: the privacy notice’s details, from your data protection officer’s review: the DPO’s contact, the legal basis of each kind of processing, how long unused accounts, server logs and backups are kept, and who hosts the service and sends its email (deploy/.env.example shows the expected wording). Each one shows as a placeholder while unset. The Italian page uses YGG_PRIVACY_<NAME>_IT where you set one, else the English value. The notice says data stays in the European Union: if a provider of yours transfers it elsewhere, publish your own notice (YGG_PRIVACY_URL).
  • YGG_PRIVACY_DATE: the date (YYYY-MM-DD) your reviewed privacy notice takes effect. Unset, the notice carries a “DRAFT, pending review” banner; set, the build requires every setting above and the four operator settings.
  • YGG_ACME_EMAIL: optional, where Let’s Encrypt writes about your certificates.
  • YGG_SMTP_*: the SMTP server: host, port, starttls or tls, user name and the sender. Its password goes in deploy/secrets/smtp_password.
  • YGG_API_IMAGE and YGG_WEB_IMAGE: the images to run (see the next step).
  • YGG_PROJECT_REQUESTS: false if the instance takes no project requests. The public form and its link disappear, and superadmins create projects themselves.
  • YGG_PUBLIC_SIGN_UP: true to let anyone create a demo account on their own: an email code, a short proof of work their browser solves (no third-party CAPTCHA), then their profile and the research-use terms. Off by default. The landing page shows “Try the demo” only in an image built with the same setting. Demo accounts see the projects they are invited to and every demo project: an open project whose admins ticked “Demo project”, which they view and try without being members (nothing they annotate is stored), with the annotations its admins publish and its processors’ outputs, and may ask to join. YGG_SIGN_UP_PER_IP (3) and YGG_SIGN_UP_PER_DAY (100) cap the accounts created per client network and for the whole instance per UTC day; a demo account fetches at most 20000 images a UTC day from demo projects.
  • YGG_TOMBSTONE_RETENTION_DAYS and YGG_PROJECT_REQUEST_RETENTION_DAYS: retention periods, 90 and 365 days by default. Deleted rows are kept as tombstones with their encrypted metadata, so members’ apps learn of deletions; handled project requests keep the requester’s name, institution and email. A daily job deletes them that many days after the deletion or the decision; an app that last synced before then lists the project again in full. 0, or the variable set to an empty value, keeps them for good. Your privacy notice states these periods: if you change them, change it too.
  • YGG_DEMO_ACCOUNT_RETENTION_DAYS: off by default, so demo accounts are kept as other accounts are. Set to a number of days (at least 30), a daily job deletes a demo account that has neither signed in nor used a session for that long, is a member of no project (an invitation counts) and has no join request waiting; standard accounts are never deleted this way, and each deletion is recorded in the instance’s audit log. The privacy notice states the period from the same setting, built into the web image: rebuild the image when you change it.
  • YGG_LLM_PROVIDER, YGG_LLM_MODEL and the other YGG_LLM_* settings: the LLM assistant, off by default.
  • YGG_LLM_PROVIDER_NAME: the AI model provider the privacy notice names, for example Anthropic, built into the web image. Unset, the notice says the service may use AI models without naming the provider.

init-env.sh writes the secrets as files in deploy/secrets/: the database password (postgres_password), the sign-in code pepper (auth_pepper), the API’s S3 secret key (s3_secret_access_key; its ID is YGG_S3_ACCESS_KEY_ID in deploy/.env; RustFS takes both as its own key), and Garage’s RPC secret and admin token (used only if you choose Garage). It also writes export_worker_token, which the export worker and the API share. And open_projects_key, the key with which the API holds the project key of open projects, those an admin chose not to encrypt end to end (public data only): leave the file empty to offer none, and never change it once a project is open. It creates three empty files for you to fill in if you need them: smtp_password, metrics_token (at least 32 characters; lets Prometheus scrape http://api:8000/metrics on the compose network), and llm_api_key (the LLM assistant’s provider key). Compose mounts each file into the containers that need it, under /run/secrets/, so no secret appears in a container’s environment. The directory is open to its owner only (mode 700) and the files are readable by all (mode 644), because each container reads them under its own user: keep the directory’s mode, or move it elsewhere and set YGG_SECRETS_DIR. Never change a secret on a running instance: the S3 key and the database password lock the API out of its own data if they change. Back up deploy/.env and deploy/secrets/ (see Backups).

Every API setting, with its default, is documented in apps/api/src/ygg_api/settings.py; deploy/compose.yml passes the ones an instance needs.

From the first tagged release on, each release publishes three images to the GitHub Container Registry, yggdrasil-api, yggdrasil-web and yggdrasil-export-worker, and lists their digests in its release notes. Pin them by digest in deploy/.env (the third is the export worker’s):

Terminal window
YGG_API_IMAGE=ghcr.io/<owner>/yggdrasil-api:X.Y.Z@sha256:…
YGG_WEB_IMAGE=ghcr.io/<owner>/yggdrasil-web:X.Y.Z@sha256:…
YGG_EXPORT_WORKER_IMAGE=ghcr.io/<owner>/yggdrasil-export-worker:X.Y.Z@sha256:…

and pull them with docker compose -f deploy/compose.yml pull.

Or build them from the checkout. The landing site bakes its own URL and the app’s into its pages, so build the web image yourself whenever you serve the landing site:

Terminal window
export YGG_VERSION="$(git describe --tags | sed 's/^v//')" \
YGG_COMMIT="$(git rev-parse HEAD)" SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
docker compose -f deploy/compose.yml build

YGG_COMMIT is what the app’s About dialog links to as its source code (AGPL §13). A modified version must link its own source: set YGG_SOURCE_URL too.

Terminal window
docker compose -f deploy/compose.yml up -d --wait

The first start creates the database, the bucket and its key, migrates, and obtains the certificates. --wait returns once every service is healthy. Open YGG_APP_URL: the sign-in page should load over HTTPS.

Superadmins approve project requests, create projects, set quotas and manage accounts. They cannot read project data unless a project admin adds them as members. Each superadmin is emailed when a verified project request arrives; the console’s Requests page turns that off for their own account, and its navigation counts the requests they have not seen.

Terminal window
docker compose -f deploy/compose.yml exec api ygg-admin create-superadmin you@example.org

The account signs in with an email code, must register a passkey, and from then on signs in with the passkey. Prefer a hardware security key, and register a second one as a spare. The console’s Instance health page then checks the database, storage, SMTP, the job worker and the migrations.

The console’s Processors page is your installation’s catalog: the processors you offer to your projects. Add a processor’s descriptor file (descriptor.json), optionally with the container image to run, pinned by digest (registry/name:tag@sha256:…), and a note on who runs it and how to get it (for example: run the image yourself, or ask us to run it for your project). Project admins then add it from the setup wizard’s Processors step instead of uploading the file, under the badge “Offered by” and your YGG_INSTITUTION_NAME.

An entry is an offer, not a verification. It holds no keys and reaches no data: each project still registers the processor in an admin’s browser with the public keys of whoever runs it, and that signed registration commits to the SHA-256 of the descriptor’s exact bytes, which the processor and every member’s app check. One descriptor can therefore be run by several institutions, each with its own keys. Withdraw stops offering an entry; projects that registered it keep it. Every change is in the instance’s audit log.

The bundled RustFS runs as one node with one copy of every object, on the rustfs-data volume of this machine. Its key is the API’s (YGG_S3_ACCESS_KEY_ID and deploy/secrets/s3_secret_access_key); migrate creates the bucket on the first start and sets its rules on every start. Only its S3 port listens: its web console and update check are off. CI runs the API’s tests and the smoke suite on it.

For redundancy, back the bucket up to another site (below), or run a RustFS cluster following RustFS’s own guide and use it as an external S3 service.

Garage was the bundled store until October 2026 and stays supported: CI runs the API’s tests and the smoke suite on it too. Add deploy/compose.garage.yml to every docker compose command:

Terminal window
docker compose -f deploy/compose.yml -f deploy/compose.garage.yml up -d --wait

RustFS then does not start. Garage runs as one node on the garage-data and garage-meta volumes, configured by deploy/garage/garage.prod.toml (LMDB metadata with snapshots every six hours, writes synced to disk, no compression), and migrate creates the API’s key and the bucket through Garage’s admin API, on a socket only migrate mounts. It uses the secrets garage_rpc_secret and garage_admin_token, which init-env.sh writes, and the region garage. Its image has no user but root, so it runs as root, without capabilities. An instance installed before RustFS became the bundled store runs on Garage: keep it this way (see Upgrades).

Another S3 service can replace RustFS: one you run (SeaweedFS was tested) or a provider’s. It needs path-style requests, presigned URLs that sign Content-Length and x-amz-checksum-sha256 (the service must verify that checksum on every upload), bucket CORS, and lifecycle rules filtered by prefix. CI runs the API’s tests (storage, uploads, exports and purges included) against SeaweedFS as well as RustFS and Garage, and the smoke suite on a stack that uses it. Avoid MinIO Community Edition, which is archived.

Create the bucket and a key for the instance on the service first: the key needs to read, write, list and delete objects, and to set the bucket’s CORS and lifecycle rules (migrate sets them on every start, as it does on RustFS). Put the key’s ID in YGG_S3_ACCESS_KEY_ID and its secret in deploy/secrets/s3_secret_access_key, then set in deploy/.env:

  • YGG_S3_BUCKET: the bucket.
  • YGG_S3_ENDPOINT: where the API, the job worker, migrate and the export worker reach the service, for example http://s3.internal:8333 or https://s3.eu-central-1.example.net.
  • YGG_S3_REGION: the region requests are signed for (us-east-1 if the service has none).
  • YGG_S3_URL and YGG_S3_UPSTREAM, one of two ways:
    • Through Caddy (a service you run): YGG_S3_URL stays the storage host of your own (https://ygg-data.example.net), and YGG_S3_UPSTREAM is the service’s address as Caddy reaches it (s3.internal:8333). Caddy passes the Host header through, so the service must answer requests for that host name on any path-style bucket, as self-hosted services do. Downloads keep Caddy’s inert headers (an application/octet-stream attachment under a sandbox CSP).
    • Directly (a provider’s public endpoint): YGG_S3_URL is the endpoint browsers reach (https://s3.eu-central-1.example.net), still a domain apart from the RP ID, and YGG_S3_UPSTREAM=none, so Caddy serves no storage host. Presigned downloads still ask for Content-Type: application/octet-stream and Content-Disposition: attachment, but the sandbox CSP and the other headers of Caddy’s storage host are only there if the provider adds them.

Then start with the override file, which leaves RustFS out and lets the services that talk to storage reach the service over the network with a route out (the export worker included, which on RustFS reaches the S3 port only):

Terminal window
docker compose -f deploy/compose.yml -f deploy/compose.external-s3.yml up -d --wait

Use the same two -f options for every later docker compose command. Differences found between services: SeaweedFS and RustFS verify each upload’s checksum but leave it out of their listings of a multipart upload’s parts, so the API relies on that verification there; SeaweedFS lists a part uploaded twice twice, and the API keeps the later one.

If the machine already runs a reverse proxy that owns ports 80 and 443 for other sites, such as a Caddy container, that proxy can terminate TLS for Yggdrasil’s host names too and pass their requests on to Yggdrasil’s own Caddy. With deploy/compose.behind-proxy.yml, Yggdrasil’s Caddy publishes no port: it joins the outer proxy’s Docker network under a fixed name and serves the web app and the API, the storage host and the landing site there over plain HTTP, for the same host names. The security headers and the Content Security Policy stay as they are, HSTS included, since browsers get them over the outer proxy’s HTTPS. The outer proxy’s other sites are not touched.

  1. The network. The outer proxy must run in a container on a user-defined Docker network (here edge; docker network create edge and attach the proxy to it if it has none). Find its address there with docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' <proxy container>. Yggdrasil’s Caddy takes X-Forwarded-For from that address only, so give the proxy a fixed one (ipv4_address in its compose file), or trust the network’s subnet instead if no other container on it is less trusted than the proxy: any container you trust can tell Yggdrasil which client a request came from.

  2. deploy/.env. Keep the https:// URLs as they are, and add:

    Terminal window
    YGG_EDGE_NETWORK=edge # the outer proxy's network (default edge)
    YGG_EDGE_ALIAS=yggdrasil # Yggdrasil's Caddy's name on it (default yggdrasil)
    YGG_TRUSTED_PROXIES=172.18.0.2 # the outer proxy's address on it, or the subnet

    Caddy listens on port YGG_HTTP_PORT (80) of that network; YGG_HTTPS_PORT, YGG_BIND_ADDRESS and YGG_ACME_EMAIL no longer apply, since the outer proxy obtains the certificates.

  3. The outer proxy. Add one site block per host name, each passing everything to Yggdrasil. For a Caddy proxy:

    (yggdrasil) {
    header {
    -Server
    -Via
    }
    reverse_proxy yggdrasil:80
    }
    ygg.example.org {
    import yggdrasil
    }
    ygg-data.example.net {
    import yggdrasil
    }
    www.ygg.example.org {
    import yggdrasil
    }

    The third block is the landing site (YGG_SITE_URL); leave it out if you serve none. Caddy’s reverse_proxy passes the Host header through unchanged, which the storage host needs (presigned URLs are signed for it), and sets X-Forwarded-For itself: from a client it does not trust, which is every client unless its own configuration has trusted_proxies, it replaces whatever the client sent with the address it received. Yggdrasil’s Caddy reads the header from the right and stops at the first address that is not a trusted proxy, so a proxy that appends the address it received (nginx’s $proxy_add_x_forwarded_for, HAProxy’s option forwardfor) is fine too; one that passes a client’s header on unchanged is not. If the outer proxy itself sits behind a CDN or load balancer it trusts, add those addresses to YGG_TRUSTED_PROXIES as well, or every client counts as the CDN. The header lines remove the Via header the outer proxy adds and any Server header, as Yggdrasil’s Caddy does. Uploads to the storage host carry up to 64 MiB per request: a proxy with a body size limit (nginx’s client_max_body_size) must allow that; Caddy has none.

  4. Start Yggdrasil, with the override file on every docker compose command (and -f deploy/compose.external-s3.yml or the others as your storage needs):

    Terminal window
    docker compose -f deploy/compose.yml -f deploy/compose.behind-proxy.yml up -d --wait
  5. Reload the outer proxy without interrupting its other sites:

    Terminal window
    docker exec <proxy container> caddy reload --config /etc/caddy/Caddyfile

    Caddy applies the new configuration without dropping connections, and keeps the old one if the new one has an error. It then obtains certificates for the new names, so they must already point at the machine (see DNS). If the Caddyfile is bind-mounted into the container as a single file, edit it in place: an editor that saves by writing a new file and renaming it over the old one (as sed -i and many editors do) leaves the container reading the old file, because the mount follows the original file. Write it with cat new-Caddyfile > Caddyfile, or mount the directory instead of the file.

To check, sign in and open Devices: your session’s network should be yours, not the outer proxy’s network.

ygg-admin backup, in the API image, writes a backup into one directory: the database (database.dump, PostgreSQL’s custom format, consistent while the instance runs), then the bucket’s objects (objects/, one file per object) without the prefix exports/, then manifest.json with the time, the release and the dump’s checksum. Run again into the same directory, it copies only what changed. Two things are not in it: deploy/.env and deploy/secrets/, which a restore needs as they were; copy them yourself, now and after every change. The export worker’s volume needs no backup.

Terminal window
# Once: a directory the API image's user (10001) can write.
sudo install -d -m 700 -o 10001 -g 10001 /srv/yggdrasil-backup
docker compose -f deploy/compose.yml run --rm --no-deps -v /srv/yggdrasil-backup:/backup \
api ygg-admin backup /backup

It prints its progress and a summary, and exits with an error if anything failed. Files of objects the bucket no longer holds stay unless you add --prune. Use the same -f options as for your other docker compose commands: the command reaches the database and storage as the API does, RustFS, Garage or external S3 alike.

Schedule it and copy it off this machine. The directory is the unit: after each backup, copy it with any tool, together with deploy/.env and deploy/secrets/, preferably one that keeps past versions (restic, borg, a versioned bucket through rclone). For example, nightly with cron from the directory that holds deploy/:

/etc/cron.d/yggdrasil-backup
30 2 * * * root cd /opt/yggdrasil && docker compose -f deploy/compose.yml run --rm --no-deps -v /srv/yggdrasil-backup:/backup api ygg-admin backup /backup --prune && restic -r sftp:backup@vault.example.org:/yggdrasil backup /srv/yggdrasil-backup deploy/.env deploy/secrets

Restore into a fresh instance with the same deploy/.env and deploy/secrets/: start it (which creates the empty bucket), stop what uses the database, restore, start again.

Terminal window
docker compose -f deploy/compose.yml up -d --wait
docker compose -f deploy/compose.yml stop api worker export-worker
docker compose -f deploy/compose.yml run --rm --no-deps -v /srv/yggdrasil-backup:/backup \
api ygg-admin restore /backup --yes
docker compose -f deploy/compose.yml up -d --wait

restore checks the manifest and the dump’s checksum and refuses while anything else is connected to the database. It replaces the database with the dump and migrates it to the running release, uploads the objects the bucket lacks, and ends the exports that were ready when the backup was taken: their archives are not in it, so they show as expired and their links say so. Admins export again if they need to.

Practise it. deploy/restore-drill.sh runs the whole cycle with these commands on a throwaway stack on this machine (the production Compose file, with self-signed TLS on *.ygg.localhost): a project with an image, an annotation and an export; a backup; the stack destroyed with its volumes and restored into a fresh one; then no archive came back, the export shows as expired, and the member signs in on a browser that holds their device key and opens the image and the annotation. It needs a checkout with pnpm install and Playwright’s Chromium (pnpm --filter @yggdrasil/web exec playwright install chromium):

Terminal window
deploy/restore-drill.sh

Project admins export a chosen part of their data as an asynchronous job: their browser grants the exact objects’ keys to an export worker the project registered, which decrypts them, packages them as a zip and stores it until it expires. A project can register a worker its institution runs on its own machine (the host never holds that worker’s keys; see the processor SDK’s README, “Export worker”), or this server’s own export worker, part of the platform and on by default.

It starts with the rest of the stack (pin YGG_EXPORT_WORKER_IMAGE with released images). On its first start it makes its keys in its volume (export-worker-state) and announces their public halves to the API with the token in deploy/secrets/export_worker_token; project admins then register it from their project, and it packages the exports of every project that did. It reaches only the API and the storage’s S3 port, on an internal network of its own, and writes only to the host’s export storage. Its archives count against each project’s storage quota.

A release whose worker writes other formats changes its descriptor: after such an upgrade, projects that registered the previous version see the registration as stale, their waiting exports are cancelled, and an admin registers the worker again (the release notes say when). How admins export, and what the archive holds, is in Exporting data.

Do not back up its volume. If the volume is lost, the worker makes new keys on its next start: projects that registered the old ones see the registration as stale, their waiting exports are cancelled, and an admin registers the worker again. To turn it off, set YGG_EXPORT_WORKER_REPLICAS=0 in deploy/.env and run docker compose -f deploy/compose.yml up -d --wait: Compose removes its container, and 0 keeps it off on later upgrades. Exports already queued on it then wait until it runs again or fail after the build timeout, and admins still see its last announcement: cancel those exports and revoke its registrations if it stays off. (Earlier versions ran it only with COMPOSE_PROFILES=export-worker in deploy/.env; that line is no longer needed and does nothing.)

An export link (https://<app>/x/<token>) works without signing in, in a browser or from the command line. One link may hold several archive parts; each is a complete zip. A browser opens the app’s link page, which lists the parts with their sizes and SHA-256s, the expiry and these commands; admins copy links from the project’s Export page.

Terminal window
# One part: follow the redirect and save the archive (-C - resumes an interrupted download).
curl -L -C - -o export.zip 'https://ygg.example.org/x/<token>'
# Any number of parts: list their URLs as plain text, then save each under its own name
# (export-<export ID>-part-0001.zip, …).
curl -L --remote-name-all $(curl -fsS 'https://ygg.example.org/x/<token>?format=txt')
# The same with xargs, one part at a time.
curl -fsS 'https://ygg.example.org/x/<token>?format=txt' | xargs -n 1 curl -L -C - -O

curl -L -O on the link itself also works for a one-part export, but names the file after the token, which is the link’s secret. Without ?format=txt, a multi-part link answers a JSON index with each part’s size and SHA-256.

An admin may protect a link with a code, which they send separately (by phone, or another channel than the link). The link page then asks for the code; curl sends it in the X-Ygg-Link-Code header on every request, the URL list’s included (never in the URL, so it stays out of access logs and shell history of URLs):

Terminal window
curl -H 'X-Ygg-Link-Code: ABCDE-FGHJK-MNPQR-STVWX' -L -C - -o export.zip 'https://ygg.example.org/x/<token>'
curl -H 'X-Ygg-Link-Code: ABCDE-FGHJK-MNPQR-STVWX' -L --remote-name-all \
$(curl -fsS -H 'X-Ygg-Link-Code: ABCDE-FGHJK-MNPQR-STVWX' 'https://ygg.example.org/x/<token>?format=txt')

Without the code a protected link answers 403. After a few wrong codes in a minute, from one network or for one link, it answers 429 until the minute ends. curl also sends the header to the storage host after the redirect; storage ignores it, and Caddy keeps no access log.

The assistant helps people with text: project admins describe a project and get a setup draft, change a draft by instruction, draft missing guidelines and translate the project’s texts; members polish the methods paragraph of the statistics page; superadmins summarize project requests; and people ask how to do something, answered from this documentation. It is off by default, and then no text leaves the installation. To turn it on, name a provider and a model in deploy/.env and put the provider’s API key in deploy/secrets/llm_api_key:

deploy/.env
YGG_LLM_PROVIDER=anthropic # or openai: any OpenAI-compatible chat completions API
YGG_LLM_MODEL=claude-opus-5-5
# YGG_LLM_BASE_URL=https://api.anthropic.com # the provider's own address by default
docker compose -f deploy/compose.yml up -d api

openai covers OpenAI, Azure OpenAI’s v1 endpoint, OpenRouter, DeepSeek, Mistral, and models you run yourself with Ollama or vLLM, which may need no key (leave the file empty). The API calls the provider from its container, so a model server of yours must be reachable from there: give its address in YGG_LLM_BASE_URL (for example http://llm.example.org:11434/v1). YGG_LLM_TURNS_PER_DAY (200) caps the model calls one account makes per UTC day, YGG_LLM_MAX_TURNS (16) one conversation, YGG_LLM_MAX_TOKENS (16000) the length of one answer and YGG_LLM_TIMEOUT_SECONDS (180) the wait; for OpenAI’s and Azure’s reasoning models set YGG_LLM_TOKEN_LIMIT_FIELD=max_completion_tokens. The key is a file, never a variable; after changing it, docker compose -f deploy/compose.yml restart api. An instance installed before the assistant existed has no such file, and Compose refuses to start without it: deploy/init-env.sh --missing writes it empty.

What leaves the installation. What people type to the assistant and the text it works on: a project’s description and setup draft (names, labels, guidelines), the texts to translate, the statistics paragraph (aggregate figures), a request’s project fields (never the requester’s name, address or institution), a help question and pages of this documentation. Never images, annotations, keys or other project data; where a project encrypts its texts, the app asks before sending them. The API stores no conversation and never logs prompts. Demo accounts cannot use the help.

Your privacy notice. An outside provider becomes a recipient of what people type to the assistant. The bundled notice says the service may use AI models; set YGG_LLM_PROVIDER_NAME (for example Anthropic) and rebuild the web image to name the provider there. The bundled notice also says data stays in the European Union: if your provider transfers it elsewhere, publish your own notice that says so and set YGG_PRIVACY_URL. A model you run on your own infrastructure adds no recipient.

The API, the job worker and the export worker emit OpenTelemetry traces and metrics over OTLP/HTTP when YGG_OTEL_ENDPOINT is set in deploy/.env; empty, they emit nothing. The API also keeps its Prometheus endpoint (http://api:8000/metrics, with the metrics_token file).

  • Traces: a span per API request, named by its method and route template (GET /v1/projects/{project_id}) with the operation ID and status code, and a child span per database statement with its verb (SELECT); a span per background job with its task name, queue and attempt; for the export worker, a span per export (its ID, destination, outcome, object and byte counts) with a span for planning and one per archive part. Each request starts its own trace: the API ignores callers’ traceparent headers.
  • Metrics: http.server.request.duration by method, route template and status; ygg.jobs.duration by task and outcome; ygg.exports.duration and ygg.exports.input_bytes by destination and outcome.
  • Never in them: request or response bodies, queries, headers, paths with IDs or export link tokens, job arguments, exception messages or stack traces (a failure records its error class only), names, decrypted data or ciphertext.

The stack includes an optional collector. Start it with the otel profile and point the services at it:

deploy/.env
YGG_OTEL_ENDPOINT=http://otel-collector:4318
docker compose -f deploy/compose.yml --profile otel up -d --wait

It receives on an internal telemetry network, scrapes the API’s /metrics with the metrics token, and by default only logs a summary of what it gets (docker compose -f deploy/compose.yml logs otel-collector). To send the data to your monitoring backend, copy deploy/otel/collector.yaml, add an exporter (such as otlp_http with your backend’s endpoint) to its pipelines, and set YGG_OTEL_COLLECTOR_CONFIG to the copy’s path. Keep the collector’s credentials for that backend in files it reads, not in its environment. The services also honour the standard OTEL_* variables (for example OTEL_TRACES_SAMPLER=traceidratio with OTEL_TRACES_SAMPLER_ARG=0.1) when a Compose override adds them to their environment. An export worker an institution runs sends to OTEL_EXPORTER_OTLP_ENDPOINT when the otel extra is installed (its image has it).

  1. Read the release notes, and take a backup.
  2. git fetch --tags && git checkout vX.Y.Z, then pin the new image digests (or rebuild), and run deploy/init-env.sh --missing to add any secret file the new release needs (it leaves the others as they are).
  3. docker compose -f deploy/compose.yml pull (or build), then docker compose -f deploy/compose.yml up -d --wait.

From Garage to RustFS (October 2026). RustFS replaced Garage as the bundled store. An instance installed before then keeps its files in Garage’s volumes: add -f deploy/compose.garage.yml to every docker compose command from now on (init-env.sh --missing reminds you). If you forget, migrate finds an empty RustFS whose bucket lacks every file the database lists, refuses to create it and stops, and nothing else starts; add the file and start again, then remove the RustFS container that start created (docker compose -f deploy/compose.yml -f deploy/compose.garage.yml --profile rustfs-unused rm -sf rustfs). To move to RustFS instead, copy the bucket (without exports/) from Garage to RustFS with any S3 tool, such as rclone, before you start without the file.

migrate runs before the new API and worker start; migrations only move forward, so going back to an older release means restoring the backup taken before the upgrade. Upgrade one release at a time when the notes ask for it.

Caddy sends, on the app’s host:

  • a Content Security Policy that allows scripts only from the app’s own origin and the one inline import map (by its hash), allows WebAssembly compilation ('wasm-unsafe-eval', for the codecs and the crypto library) but never string evaluation ('unsafe-eval'), lets the app connect only to itself and to the storage host, and forbids framing (frame-ancestors 'none');
  • Strict-Transport-Security for two years, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy: same-origin, and a Permissions-Policy that turns off the camera, location and other device APIs and allows the microphone to the app itself only (readers dictate captions; the browser still asks each reader).

Every script and stylesheet the app’s page loads carries a Subresource Integrity hash, and an import map lists the hash of every script the page imports later. Browsers check no hash for web workers: the workers (the crypto worker among them), the modules they import and the WebAssembly codecs they fetch are covered by the image’s SHA256SUMS and the reproducible build instead (below). The API’s /metrics, /docs and /openapi.json are not exposed; Prometheus scrapes http://api:8000/metrics on the compose network with YGG_METRICS_TOKEN.

Because the server delivers the web app, a malicious operator could change it. Releases make that visible:

  • Signed images. Each image is signed with Sigstore and carries a build provenance attestation from GitHub Actions. Verify both against the release workflow on the release’s tag, not just the repository: cosign verify <image>@<digest> --certificate-identity https://github.com/<owner>/yggdrasil4/.github/workflows/release.yml@refs/tags/<tag> --certificate-oidc-issuer https://token.actions.githubusercontent.com, and gh attestation verify oci://<image>@<digest> --repo <owner>/yggdrasil4 --cert-identity with the same identity. The release notes give the exact commands.
  • Reproducible builds. Every image’s build depends only on the release’s commit, version and commit time (and, for the web image, the landing URLs the release notes list); the base images, lockfiles and BuildKit are pinned, and layer timestamps are the commit’s time. CI builds each image twice from every commit and checks that the digests match (deploy/reproducible.sh, which lists any file that differs). The release notes list each platform’s image digest and the command that rebuilds them from the tag; compare the digests it prints. More simply, rebuild the web image from the tag (step 4) and compare /etc/caddy/app-SHA256SUMS in it with the release’s web-app-SHA256SUMS: the files must be identical. Images are published for linux/amd64 and linux/arm64; the web app’s files are the same on both.
  • What the browser loads. The integrity hashes in index.html must match those files; a browser refuses a page script that does not match its hash. Web workers, their imports and the WebAssembly codecs carry no hash a browser checks, so compare them with SHA256SUMS, which lists every file of the build (the image build fails if a script or codec is missing from it).

For evaluation on one machine, with synthetic data:

Terminal window
git clone https://github.com/lorenzobrg/yggdrasil4 yggdrasil && cd yggdrasil
make setup # Node 26, pnpm 12, uv and Docker are prerequisites
make dev-up # PostgreSQL, RustFS and Mailpit on 127.0.0.1, then migrate
make seed # a demo project with synthetic CT, MR and photo data
make dev-api # the API on 127.0.0.1:8000
make worker # the background jobs
make dev-web # the web app on http://localhost:5173

The development services use fixed credentials and a mail catcher (Mailpit on http://localhost:8025, where sign-in codes arrive). Never expose them to a network.

To try the production stack itself on one machine, make smoke builds the images, starts deploy/compose.yml with self-signed certificates on *.ygg.localhost and runs its checks.