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.
What an instance needs
Section titled “What an instance needs”- A machine (x86-64 or 64-bit ARM: the images are built for
linux/amd64andlinux/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.
What runs
Section titled “What runs”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.
Client addresses
Section titled “Client addresses”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_SUBNET6indeploy/.envsets its subnet):Terminal window docker compose -f deploy/compose.yml -f deploy/compose.ipv6.yml up -d --waitOr turn the userland proxy off (
"userland-proxy": falsein/etc/docker/daemon.json, then restart Docker). IPv6 clients then cannot reach an IPv4-only network at all, so publish anAAAArecord 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-Forit receives unless it comes fromYGG_TRUSTED_PROXIESindeploy/.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.
Install
Section titled “Install”1. Get the release
Section titled “1. Get the release”git clone https://github.com/lorenzobrg/yggdrasil4 yggdrasil && cd yggdrasilgit checkout vX.Y.Z # the release you install2. DNS
Section titled “2. DNS”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.
3. Configure
Section titled “3. Configure”deploy/init-env.sh # writes deploy/.env, and deploy/secrets/ with fresh random secretsThen edit deploy/.env (its comments explain every line):
YGG_APP_URL: the web app, for examplehttps://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 examplehttps://ygg-data.example.net. Put it on a separate registrable domain, never onYGG_WEBAUTHN_RP_IDor 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 (anapplication/octet-streamattachment 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 theYGG_PRIVACY_*settings below publishes its own this way rather than editingprivacy.mdx.YGG_OPERATOR,YGG_OPERATOR_ADDRESS,YGG_OPERATOR_EMAILandYGG_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 exampleModena, 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 exampleAImageLab, 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.exampleshows the expected wording). Each one shows as a placeholder while unset. The Italian page usesYGG_PRIVACY_<NAME>_ITwhere 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,starttlsortls, user name and the sender. Its password goes indeploy/secrets/smtp_password.YGG_API_IMAGEandYGG_WEB_IMAGE: the images to run (see the next step).YGG_PROJECT_REQUESTS:falseif the instance takes no project requests. The public form and its link disappear, and superadmins create projects themselves.YGG_PUBLIC_SIGN_UP:trueto 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) andYGG_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_DAYSandYGG_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_MODELand the otherYGG_LLM_*settings: the LLM assistant, off by default.YGG_LLM_PROVIDER_NAME: the AI model provider the privacy notice names, for exampleAnthropic, 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.
4. Images
Section titled “4. Images”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):
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:
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 buildYGG_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.
5. Start
Section titled “5. Start”docker compose -f deploy/compose.yml up -d --waitThe 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.
The first superadmin
Section titled “The first superadmin”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.
docker compose -f deploy/compose.yml exec api ygg-admin create-superadmin you@example.orgThe 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.
Processor catalog
Section titled “Processor catalog”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.
Storage
Section titled “Storage”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
Section titled “Garage”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:
docker compose -f deploy/compose.yml -f deploy/compose.garage.yml up -d --waitRustFS 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).
External S3
Section titled “External S3”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,migrateand the export worker reach the service, for examplehttp://s3.internal:8333orhttps://s3.eu-central-1.example.net.YGG_S3_REGION: the region requests are signed for (us-east-1if the service has none).YGG_S3_URLandYGG_S3_UPSTREAM, one of two ways:- Through Caddy (a service you run):
YGG_S3_URLstays the storage host of your own (https://ygg-data.example.net), andYGG_S3_UPSTREAMis 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 (anapplication/octet-streamattachment under a sandbox CSP). - Directly (a provider’s public endpoint):
YGG_S3_URLis the endpoint browsers reach (https://s3.eu-central-1.example.net), still a domain apart from the RP ID, andYGG_S3_UPSTREAM=none, so Caddy serves no storage host. Presigned downloads still ask forContent-Type: application/octet-streamandContent-Disposition: attachment, but the sandbox CSP and the other headers of Caddy’s storage host are only there if the provider adds them.
- Through Caddy (a service you run):
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):
docker compose -f deploy/compose.yml -f deploy/compose.external-s3.yml up -d --waitUse 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.
Behind an existing reverse proxy
Section titled “Behind an existing reverse proxy”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.
-
The network. The outer proxy must run in a container on a user-defined Docker network (here
edge;docker network create edgeand attach the proxy to it if it has none). Find its address there withdocker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' <proxy container>. Yggdrasil’s Caddy takesX-Forwarded-Forfrom that address only, so give the proxy a fixed one (ipv4_addressin 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. -
deploy/.env. Keep thehttps://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 subnetCaddy listens on port
YGG_HTTP_PORT(80) of that network;YGG_HTTPS_PORT,YGG_BIND_ADDRESSandYGG_ACME_EMAILno longer apply, since the outer proxy obtains the certificates. -
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’sreverse_proxypasses theHostheader through unchanged, which the storage host needs (presigned URLs are signed for it), and setsX-Forwarded-Foritself: from a client it does not trust, which is every client unless its own configuration hastrusted_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’soption 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 toYGG_TRUSTED_PROXIESas well, or every client counts as the CDN. Theheaderlines remove theViaheader the outer proxy adds and anyServerheader, 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’sclient_max_body_size) must allow that; Caddy has none. -
Start Yggdrasil, with the override file on every
docker composecommand (and-f deploy/compose.external-s3.ymlor the others as your storage needs):Terminal window docker compose -f deploy/compose.yml -f deploy/compose.behind-proxy.yml up -d --wait -
Reload the outer proxy without interrupting its other sites:
Terminal window docker exec <proxy container> caddy reload --config /etc/caddy/CaddyfileCaddy 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 -iand many editors do) leaves the container reading the old file, because the mount follows the original file. Write it withcat 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.
Backups
Section titled “Backups”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.
# 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 /backupIt 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/:
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/secretsRestore 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.
docker compose -f deploy/compose.yml up -d --waitdocker compose -f deploy/compose.yml stop api worker export-workerdocker compose -f deploy/compose.yml run --rm --no-deps -v /srv/yggdrasil-backup:/backup \ api ygg-admin restore /backup --yesdocker compose -f deploy/compose.yml up -d --waitrestore 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):
deploy/restore-drill.shExport worker
Section titled “Export worker”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.)
Export downloads
Section titled “Export downloads”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.
# 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 - -Ocurl -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):
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.
LLM assistant
Section titled “LLM assistant”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:
YGG_LLM_PROVIDER=anthropic # or openai: any OpenAI-compatible chat completions APIYGG_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 apiopenai 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.
Observability
Section titled “Observability”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’traceparentheaders. - Metrics:
http.server.request.durationby method, route template and status;ygg.jobs.durationby task and outcome;ygg.exports.durationandygg.exports.input_bytesby 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:
YGG_OTEL_ENDPOINT=http://otel-collector:4318
docker compose -f deploy/compose.yml --profile otel up -d --waitIt 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).
Upgrades
Section titled “Upgrades”- Read the release notes, and take a backup.
git fetch --tags && git checkout vX.Y.Z, then pin the new image digests (or rebuild), and rundeploy/init-env.sh --missingto add any secret file the new release needs (it leaves the others as they are).docker compose -f deploy/compose.yml pull(orbuild), thendocker 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.
Security headers
Section titled “Security headers”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-Securityfor two years,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY,Cross-Origin-Opener-PolicyandCross-Origin-Resource-Policy: same-origin, and aPermissions-Policythat 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.
Verify what you run
Section titled “Verify what you run”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, andgh attestation verify oci://<image>@<digest> --repo <owner>/yggdrasil4 --cert-identitywith 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-SHA256SUMSin it with the release’sweb-app-SHA256SUMS: the files must be identical. Images are published forlinux/amd64andlinux/arm64; the web app’s files are the same on both. - What the browser loads. The integrity hashes in
index.htmlmust 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 withSHA256SUMS, which lists every file of the build (the image build fails if a script or codec is missing from it).
Try it from source
Section titled “Try it from source”For evaluation on one machine, with synthetic data:
git clone https://github.com/lorenzobrg/yggdrasil4 yggdrasil && cd yggdrasilmake setup # Node 26, pnpm 12, uv and Docker are prerequisitesmake dev-up # PostgreSQL, RustFS and Mailpit on 127.0.0.1, then migratemake seed # a demo project with synthetic CT, MR and photo datamake dev-api # the API on 127.0.0.1:8000make worker # the background jobsmake dev-web # the web app on http://localhost:5173The 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.