Salta ai contenuti

Installazione con Docker Compose

Yggdrasil gira su una macchina con Docker Compose. Ogni installazione appartiene a un’istituzione e contiene solo i progetti di quell’istituzione; ogni installazione esegue la stessa release, senza funzionalità chiuse.

  • Una macchina (x86-64 o ARM a 64 bit: le immagini sono compilate per linux/amd64 e linux/arm64) con Docker Engine e il plugin Compose. Minimo indicativo: 4 vCPU, 8 GB di RAM, più il disco per i dati cifrati.
  • Due nomi host che puntano a essa, tre con il sito di presentazione: l’app web (per esempio ygg.example.org), l’endpoint di storage verso cui caricano i browser, su un dominio a sé (ygg-data.example.net), e facoltativamente il sito di presentazione e la documentazione. Le passkey richiedono HTTPS su un nome host reale.
  • Le porte 80 e 443 raggiungibili da internet, così Caddy può ottenere i certificati da Let’s Encrypt. Se sulla macchina un altro reverse proxy le occupa già, Yggdrasil può stare dietro di esso.
  • Un server SMTP che invii dal tuo dominio. I codici di accesso, gli inviti e le risposte alle richieste di progetto viaggiano per email.

I nomi example.org in questa pagina sono segnaposto: usa i tuoi.

deploy/compose.yml avvia questi container. Solo Caddy pubblica porte.

Servizio Ruolo
caddy TLS per ogni nome host, certificati inclusi; serve l’app web e il sito di presentazione; inoltra /v1 e i link di esportazione (/x/…) all’API e l’host di storage a RustFS (o a Garage, o a un altro servizio S3). Imposta gli header di sicurezza descritti sotto.
api Server FastAPI: metadati, chiavi cifrate, URL di storage prefirmati, accesso. Non vede mai dati in chiaro.
worker Job in background: email, lease scaduti dei processori, pulizia delle sessioni, storage dei progetti eliminati.
migrate Gira a ogni avvio: migrazioni del database, poi il bucket (YGG_S3_BUCKET) se manca, poi le sue regole CORS e di lifecycle. Non crea mai il bucket mentre il database elenca file archiviati: uno storage che non li ha è quello sbagliato, quindi si ferma (vedi Aggiornamenti). I file cifrati stanno sotto p/, gli archivi di esportazione sotto exports/, dove ogni oggetto scade al massimo dopo 34 giorni, una rete di sicurezza dietro l’eliminazione alla scadenza di ogni esportazione. Poi termina.
postgres PostgreSQL 17: progetti, membri, righe di indice cifrate e il registro di audit.
rustfs RustFS, a nodo singolo: i file cifrati, e gli archivi di esportazione fino alla loro scadenza. La sua chiave è la chiave S3 dell’API. Garage può prenderne il posto.
export-worker Il worker di esportazione del server ospitante (vedi Worker di esportazione), che decifra ciò che gli amministratori del progetto gli concedono e lo impacchetta in exports/. Attivo per impostazione predefinita; puoi disattivarlo.

Il database e lo storage stanno su una rete interna senza via d’uscita. Ogni container gira senza capability Linux, non può acquisire privilegi e ha un filesystem root in sola lettura; nessuno gira come root (tranne Garage, se lo scegli: la sua immagine non ha altri utenti), e Caddy si lega alle porte 80 e 443 come utente ordinario. Nessun container ha un segreto nel proprio ambiente, dove docker inspect lo mostrerebbe: i segreti sono file (vedi Configura). La console web di RustFS è disattivata, e così l’API di amministrazione di Caddy. L’API si fida di X-Forwarded-For solo dalla rete di Caddy, quindi i limiti di frequenza e i registri di accesso vedono l’indirizzo che Caddy ha ricevuto; Indirizzi dei client spiega come fare in modo che sia quello del client.

I limiti di accesso contano le richieste per client: un indirizzo IPv4, o l’intero /64 di un client IPv6. Funzionano come previsto solo quando Caddy riceve l’indirizzo proprio di ciascun client:

  • I client IPv4 su altri host mantengono il loro indirizzo: Docker li inoltra a Caddy con le sue regole NAT.

  • I client IPv6 mantengono il loro solo se la rete di Compose ha IPv6. Senza, il proxy userland di Docker li inoltra a Caddy su IPv4, e ognuno di loro arriva come il gateway della rete, condividendo un unico limite. Avvia lo stack con l’overlay IPv6 (Docker 27 o successivo, IPv6 sull’host; YGG_PROXY_SUBNET6 in deploy/.env ne imposta la subnet):

    Finestra del terminale
    docker compose -f deploy/compose.yml -f deploy/compose.ipv6.yml up -d --wait

    Oppure disattiva il proxy userland ("userland-proxy": false in /etc/docker/daemon.json, poi riavvia Docker). I client IPv6 allora non possono raggiungere affatto una rete solo IPv4, quindi pubblica un record AAAA solo con l’overlay.

  • Le connessioni dall’host stesso, o da container attraverso l’indirizzo dell’host, arrivano sempre come il gateway.

  • Dietro un load balancer o un altro proxy, Caddy vede solo il balancer. Caddy non si fida di alcun X-Forwarded-For che riceve, se non arriva da YGG_TRUSTED_PROXIES in deploy/.env: impostalo agli indirizzi del balancer (o a intervalli, separati da spazi o virgole), e assicurati che il balancer aggiunga all’header l’indirizzo che ha ricevuto invece di inoltrare quello del client. Un reverse proxy sulla stessa macchina è descritto in Dietro un reverse proxy esistente.

Per verificare, accedi e apri Dispositivi: la rete della tua sessione dovrebbe essere la tua, non la rete del proxy (172.30.36.0/24 a meno che YGG_PROXY_SUBNET non dica altro). I client dietro uno stesso NAT condividono un indirizzo qualunque cosa tu faccia; per questo le richieste di codici via email sono limitate anche per indirizzo e per rete, con un tetto più alto per indirizzo, così una rete non può esaurire il limite di un’altra.

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

Fai puntare un record A (e AAAA, se l’host ha IPv6) per ogni nome host alla macchina. Caddy chiede un certificato a Let’s Encrypt la prima volta che ciascun nome viene richiesto e lo rinnova da solo; per TLS non serve altro.

Per un’istanza su una rete privata senza DNS pubblico, usa nomi sotto .internal: Caddy allora emette i certificati dalla propria autorità di certificazione, la cui radice (/data/caddy/pki/authorities/local/root.crt nel container caddy) deve essere considerata attendibile dal browser di ogni membro.

Finestra del terminale
deploy/init-env.sh # writes deploy/.env, and deploy/secrets/ with fresh random secrets

Poi modifica deploy/.env (i suoi commenti spiegano ogni riga):

  • YGG_APP_URL: l’app web, per esempio https://ygg.example.org. L’API risponde sullo stesso host sotto /v1.
  • YGG_WEBAUTHN_RP_ID: il nome host dell’app, o un suo dominio padre. Le passkey sono legate a esso: cambiarlo in seguito invalida ogni passkey.
  • YGG_S3_URL: l’host di storage che i browser raggiungono, per esempio https://ygg-data.example.net. Mettilo su un dominio registrabile separato, mai su YGG_WEBAUTHN_RP_ID o su un nome sotto di esso: altrimenti una pagina servita dall’host di storage potrebbe chiedere le passkey dei tuoi utenti e impostare cookie per l’app. L’API si rifiuta di avviarsi con un host di storage sotto l’RP ID. Caddy serve ogni oggetto su di esso come download (un allegato application/octet-stream sotto una CSP sandbox).
  • YGG_SITE_URL: facoltativo, il sito di presentazione e la documentazione sul loro host. Se è impostato, l’app web collega le condizioni d’uso del sito (/terms/) e l’informativa privacy (/privacy/) dal suo footer, dalla pagina di accesso e dal modulo di richiesta; senza, l’app non mostra nessuno dei due link. Entrambe le pagine nominano il gestore dalle impostazioni di build qui sotto; senza di esse mostrano segnaposto e un banner di bozza.
  • YGG_TERMS_URL, YGG_PRIVACY_URL: facoltativi (https), dove la tua istituzione pubblica le proprie condizioni d’uso o la propria informativa privacy, per esempio un’informativa che il suo responsabile della protezione dei dati tiene insieme alle altre informative dell’istituzione. Se sono impostati, l’app collega lì invece che alla pagina del sito. Un’installazione la cui informativa privacy differisce da quella fornita per più delle impostazioni YGG_PRIVACY_* qui sotto pubblica la propria in questo modo invece di modificare privacy.mdx.
  • YGG_OPERATOR, YGG_OPERATOR_ADDRESS, YGG_OPERATOR_EMAIL e YGG_OPERATOR_COURTS: la tua istituzione, che gestisce questa installazione per i propri progetti: il nome, l’indirizzo postale, l’email di contatto e il foro competente per le condizioni d’uso (per esempio Modena, Italy). Le condizioni d’uso e l’informativa privacy del sito di presentazione la nominano; le immagini rilasciate non nominano alcun gestore.
  • YGG_INSTITUTION_NAME: l’istituzione che gestisce questa installazione, come la nomina l’app, per esempio AImageLab, University of Modena and Reggio Emilia. L’app web la mostra nell’intestazione, nella pagina di accesso e nel riquadro Informazioni, sul badge dei processori offerti dal tuo catalogo (“Offerto da …”), e ogni email termina con essa. Se non è impostata, l’app non nomina alcuna istituzione. L’API la legge all’avvio, quindi cambiarla lì richiede solo un riavvio; anche la pagina di presentazione la nomina, ed è compilata con essa come le impostazioni del gestore qui sopra. Qualunque cosa imposti, il footer accredita lo sviluppatore del software (“Sviluppato da AImageLab”).
  • YGG_TERMS_DATE: la data (YYYY-MM-DD) da cui entrano in vigore le tue condizioni d’uso revisionate. Se non è impostata, le condizioni portano un banner “BOZZA, in attesa di revisione”; se è impostata, la build richiede le quattro impostazioni del gestore.
  • 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: i dettagli dell’informativa privacy, dalla revisione del tuo responsabile della protezione dei dati: il contatto dell’RPD, la base giuridica di ciascun trattamento, per quanto tempo sono conservati gli account inutilizzati, i log del server e i backup, e chi ospita il servizio e ne invia le email (deploy/.env.example mostra la formulazione attesa). Ognuno appare come segnaposto finché non è impostato. La pagina italiana usa YGG_PRIVACY_<NOME>_IT dove ne imposti uno, altrimenti il valore inglese. L’informativa dice che i dati restano nell’Unione europea: se un tuo fornitore li trasferisce altrove, pubblica la tua informativa (YGG_PRIVACY_URL).
  • YGG_PRIVACY_DATE: la data (YYYY-MM-DD) da cui entra in vigore la tua informativa privacy revisionata. Se non è impostata, l’informativa porta un banner “BOZZA, in attesa di revisione”; se è impostata, la build richiede tutte le impostazioni qui sopra e le quattro impostazioni del gestore.
  • YGG_ACME_EMAIL: facoltativo, dove Let’s Encrypt scrive riguardo ai tuoi certificati.
  • YGG_SMTP_*: il server SMTP: host, porta, starttls o tls, nome utente e mittente. La sua password va in deploy/secrets/smtp_password.
  • YGG_API_IMAGE e YGG_WEB_IMAGE: le immagini da eseguire (vedi il passo successivo).
  • YGG_PROJECT_REQUESTS: false se l’istanza non accetta richieste di progetto. Il modulo pubblico e il suo link scompaiono, e i superamministratori creano i progetti da sé.
  • YGG_PUBLIC_SIGN_UP: true per permettere a chiunque di creare da sé un account demo: un codice via email, una breve prova di lavoro che il browser risolve (nessun CAPTCHA di terze parti), poi il profilo e le condizioni d’uso per la ricerca. Disattivato per impostazione predefinita. La landing page mostra “Prova la demo” solo in un’immagine costruita con la stessa impostazione. Gli account demo vedono i progetti a cui sono invitati e ogni progetto dimostrativo: un progetto aperto i cui amministratori hanno spuntato “Progetto dimostrativo”, che vedono e provano senza esserne membri (nulla di ciò che annotano viene conservato), con le annotazioni che i suoi amministratori pubblicano e i risultati dei suoi processori, e a cui possono chiedere di partecipare. YGG_SIGN_UP_PER_IP (3) e YGG_SIGN_UP_PER_DAY (100) limitano gli account creati per rete client e per l’intera istanza per giorno UTC; un account demo scarica al massimo 20000 immagini per giorno UTC dai progetti dimostrativi.
  • YGG_TOMBSTONE_RETENTION_DAYS e YGG_PROJECT_REQUEST_RETENTION_DAYS: periodi di conservazione, 90 e 365 giorni per impostazione predefinita. Le righe eliminate sono conservate come tombstone con i loro metadati cifrati, così le app dei membri vengono a sapere delle eliminazioni; le richieste di progetto gestite conservano nome, istituzione ed email del richiedente. Un job giornaliero le elimina quel numero di giorni dopo l’eliminazione o la decisione; un’app che si è sincronizzata l’ultima volta prima di allora elenca di nuovo il progetto per intero. 0, o la variabile impostata a un valore vuoto, le conserva per sempre. La tua informativa privacy indica questi periodi: se li cambi, cambia anche quella.
  • YGG_DEMO_ACCOUNT_RETENTION_DAYS: disattivato per impostazione predefinita, così gli account demo sono conservati come gli altri account. Impostato a un numero di giorni (almeno 30), un job giornaliero elimina un account demo che per quel periodo non ha né eseguito l’accesso né usato una sessione, non è membro di alcun progetto (un invito conta) e non ha richieste di partecipazione in attesa; gli account standard non vengono mai eliminati così, e ogni eliminazione è registrata nel registro di audit dell’istanza. L’informativa privacy indica il periodo dalla stessa impostazione, inclusa nell’immagine web: ricostruisci l’immagine quando la cambi.
  • YGG_LLM_PROVIDER, YGG_LLM_MODEL e le altre impostazioni YGG_LLM_*: l’assistente LLM, disattivato per impostazione predefinita.
  • YGG_LLM_PROVIDER_NAME: il fornitore di modelli di IA che l’informativa privacy nomina, per esempio Anthropic, incluso nell’immagine web. Se non è impostato, l’informativa dice che il servizio può usare modelli di IA senza nominare il fornitore.

init-env.sh scrive i segreti come file in deploy/secrets/: la password del database (postgres_password), il pepper dei codici di accesso (auth_pepper), la chiave segreta S3 dell’API (s3_secret_access_key; il suo ID è YGG_S3_ACCESS_KEY_ID in deploy/.env; RustFS li usa entrambi come propria chiave), e il segreto RPC e il token di amministrazione di Garage (usati solo se scegli Garage). Scrive anche export_worker_token, che il worker di esportazione e l’API condividono. E open_projects_key, la chiave con cui l’API custodisce la chiave dei progetti aperti, quelli che un admin ha scelto di non cifrare end-to-end (solo dati pubblici): lascia il file vuoto per non offrirne, e non cambiarla mai dopo che un progetto è aperto. Crea tre file vuoti da compilare se ti servono: smtp_password, metrics_token (almeno 32 caratteri; permette a Prometheus di raccogliere http://api:8000/metrics sulla rete di Compose), e llm_api_key (la chiave del fornitore dell’assistente LLM). Compose monta ogni file nei container che ne hanno bisogno, sotto /run/secrets/, così nessun segreto compare nell’ambiente di un container. La directory è accessibile solo al proprietario (modo 700) e i file sono leggibili da tutti (modo 644), perché ogni container li legge con il proprio utente: mantieni il modo della directory, oppure spostala altrove e imposta YGG_SECRETS_DIR. Non cambiare mai un segreto su un’istanza in esecuzione: se cambiano, la chiave S3 e la password del database chiudono l’API fuori dai suoi stessi dati. Fai il backup di deploy/.env e deploy/secrets/ (vedi Backup).

Ogni impostazione dell’API, con il suo valore predefinito, è documentata in apps/api/src/ygg_api/settings.py; deploy/compose.yml passa quelle di cui un’istanza ha bisogno.

Dalla prima release con tag in poi, ogni release pubblica tre immagini sul GitHub Container Registry, yggdrasil-api, yggdrasil-web e yggdrasil-export-worker, e ne elenca i digest nelle note di rilascio. Fissale per digest in deploy/.env (la terza è quella del worker di esportazione):

Finestra del terminale
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:…

e scaricale con docker compose -f deploy/compose.yml pull.

Oppure compilale dal checkout. Il sito di presentazione incorpora nelle sue pagine il proprio URL e quello dell’app, quindi compila tu l’immagine web ogni volta che servi il sito di presentazione:

Finestra del terminale
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 è ciò a cui il dialogo Informazioni dell’app rimanda come codice sorgente (AGPL §13). Una versione modificata deve rimandare al proprio sorgente: imposta anche YGG_SOURCE_URL.

Finestra del terminale
docker compose -f deploy/compose.yml up -d --wait

Il primo avvio crea il database, il bucket e la sua chiave, esegue le migrazioni e ottiene i certificati. --wait ritorna quando ogni servizio è in salute. Apri YGG_APP_URL: la pagina di accesso dovrebbe caricarsi su HTTPS.

I superamministratori approvano le richieste di progetto, creano i progetti, impostano le quote e gestiscono gli account. Non possono leggere i dati di un progetto a meno che un amministratore del progetto non li aggiunga come membri. Ogni superamministratore riceve un’email quando arriva una richiesta di progetto verificata; la pagina Richieste della console lo disattiva per il proprio account, e la sua navigazione conta le richieste che non ha ancora visto.

Finestra del terminale
docker compose -f deploy/compose.yml exec api ygg-admin create-superadmin you@example.org

L’account accede con un codice via email, deve registrare una passkey, e da quel momento accede con la passkey. Preferisci una chiave di sicurezza hardware, e registrane una seconda come scorta. La pagina Stato dell’istanza della console controlla poi il database, lo storage, SMTP, il worker dei job e le migrazioni.

La pagina Processori della console è il catalogo della tua installazione: i processori che offri ai tuoi progetti. Aggiungi il file descrittore di un processore (descriptor.json), facoltativamente con l’immagine container da eseguire, fissata per digest (registry/name:tag@sha256:…), e una nota su chi lo esegue e come ottenerlo (per esempio: esegui l’immagine da solo, o chiedici di eseguirlo per il tuo progetto). Gli amministratori del progetto lo aggiungono poi dal passaggio Processori della procedura guidata di configurazione invece di caricare il file, sotto il badge “Offerto da” e il tuo YGG_INSTITUTION_NAME.

Una voce è un’offerta, non una verifica. Non contiene chiavi e non raggiunge alcun dato: ogni progetto registra comunque il processore nel browser di un amministratore con le chiavi pubbliche di chi lo esegue, e quella registrazione firmata si impegna sullo SHA-256 dei byte esatti del descrittore, che il processore e l’app di ogni membro controllano. Uno stesso descrittore può quindi essere eseguito da più istituzioni, ognuna con le proprie chiavi. Ritira smette di offrire una voce; i progetti che l’hanno registrata la mantengono. Ogni modifica è nel registro di audit dell’istanza.

Il RustFS incluso gira come un solo nodo con una copia di ogni oggetto, sul volume rustfs-data di questa macchina. La sua chiave è quella dell’API (YGG_S3_ACCESS_KEY_ID e deploy/secrets/s3_secret_access_key); migrate crea il bucket al primo avvio e ne imposta le regole a ogni avvio. Ascolta solo la sua porta S3: la console web e il controllo degli aggiornamenti sono disattivati. La CI esegue su di esso i test dell’API e la suite smoke.

Per la ridondanza, fai il backup del bucket su un altro sito (vedi sotto), oppure esegui un cluster RustFS seguendo la guida di RustFS stesso e usalo come servizio S3 esterno.

Garage è stato lo storage incluso fino a ottobre 2026 e resta supportato: la CI esegue anche su di esso i test dell’API e la suite smoke. Aggiungi deploy/compose.garage.yml a ogni comando docker compose:

Finestra del terminale
docker compose -f deploy/compose.yml -f deploy/compose.garage.yml up -d --wait

RustFS allora non parte. Garage gira come un solo nodo sui volumi garage-data e garage-meta, configurato da deploy/garage/garage.prod.toml (metadati LMDB con snapshot ogni sei ore, scritture sincronizzate su disco, nessuna compressione), e migrate crea la chiave dell’API e il bucket tramite l’API di amministrazione di Garage, su un socket che solo migrate monta. Usa i segreti garage_rpc_secret e garage_admin_token, che init-env.sh scrive, e la regione garage. La sua immagine non ha altri utenti oltre a root, quindi gira come root, senza capability. Un’istanza installata prima che RustFS diventasse lo storage incluso gira su Garage: tienila così (vedi Aggiornamenti).

Un altro servizio S3 può sostituire RustFS: uno che gestisci tu (SeaweedFS è stato provato) o quello di un fornitore. Richiede richieste path-style, URL prefirmati che firmino Content-Length e x-amz-checksum-sha256 (il servizio deve verificare quel checksum a ogni caricamento), CORS sul bucket e regole di lifecycle filtrate per prefisso. La CI esegue i test dell’API (storage, caricamenti, esportazioni ed eliminazioni definitive inclusi) su SeaweedFS oltre che su RustFS e Garage, e la suite smoke su uno stack che lo usa. Evita MinIO Community Edition, che è archiviato.

Prima crea sul servizio il bucket e una chiave per l’istanza: la chiave deve poter leggere, scrivere, elencare ed eliminare oggetti, e impostare le regole CORS e di lifecycle del bucket (migrate le imposta a ogni avvio, come fa su RustFS). Metti l’ID della chiave in YGG_S3_ACCESS_KEY_ID e il suo segreto in deploy/secrets/s3_secret_access_key, poi imposta in deploy/.env:

  • YGG_S3_BUCKET: il bucket.
  • YGG_S3_ENDPOINT: dove l’API, il worker dei job, migrate e il worker di esportazione raggiungono il servizio, per esempio http://s3.internal:8333 o https://s3.eu-central-1.example.net.
  • YGG_S3_REGION: la regione per cui sono firmate le richieste (us-east-1 se il servizio non ne ha).
  • YGG_S3_URL e YGG_S3_UPSTREAM, in uno di due modi:
    • Attraverso Caddy (un servizio che gestisci tu): YGG_S3_URL resta il tuo host di storage (https://ygg-data.example.net), e YGG_S3_UPSTREAM è l’indirizzo del servizio come lo raggiunge Caddy (s3.internal:8333). Caddy inoltra l’header Host così com’è, quindi il servizio deve rispondere alle richieste per quel nome host su qualsiasi bucket path-style, come fanno i servizi self-hosted. I download mantengono gli header inerti di Caddy (un allegato application/octet-stream sotto una CSP sandbox).
    • Direttamente (l’endpoint pubblico di un fornitore): YGG_S3_URL è l’endpoint che raggiungono i browser (https://s3.eu-central-1.example.net), sempre su un dominio separato dall’RP ID, e YGG_S3_UPSTREAM=none, così Caddy non serve alcun host di storage. I download prefirmati chiedono comunque Content-Type: application/octet-stream e Content-Disposition: attachment, ma la CSP sandbox e gli altri header dell’host di storage di Caddy ci sono solo se li aggiunge il fornitore.

Poi avvia con il file di override, che lascia fuori RustFS e permette ai servizi che parlano con lo storage di raggiungere il servizio su una rete con una via d’uscita (incluso il worker di esportazione, che su RustFS raggiunge solo la porta S3):

Finestra del terminale
docker compose -f deploy/compose.yml -f deploy/compose.external-s3.yml up -d --wait

Usa le stesse due opzioni -f per ogni comando docker compose successivo. Differenze riscontrate tra i servizi: SeaweedFS e RustFS verificano il checksum di ogni caricamento ma lo omettono dagli elenchi delle parti di un caricamento multipart, quindi lì l’API si affida a quella verifica; SeaweedFS elenca due volte una parte caricata due volte, e l’API tiene la più recente.

Se sulla macchina gira già un reverse proxy che occupa le porte 80 e 443 per altri siti, per esempio un container Caddy, quel proxy può terminare TLS anche per i nomi host di Yggdrasil e passarne le richieste al Caddy di Yggdrasil. Con deploy/compose.behind-proxy.yml il Caddy di Yggdrasil non pubblica porte: entra nella rete Docker del proxy esterno con un nome fisso e lì serve l’app web e l’API, l’host di storage e il sito di presentazione in HTTP semplice, per gli stessi nomi host. Gli header di sicurezza e la Content Security Policy restano come sono, HSTS incluso, perché i browser li ricevono attraverso l’HTTPS del proxy esterno. Gli altri siti del proxy esterno non vengono toccati.

  1. La rete. Il proxy esterno deve girare in un container su una rete Docker definita dall’utente (qui edge; docker network create edge e collega il proxy, se non ne ha una). Trova il suo indirizzo su quella rete con docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' <container del proxy>. Il Caddy di Yggdrasil accetta X-Forwarded-For solo da quell’indirizzo, quindi dai al proxy un indirizzo fisso (ipv4_address nel suo file compose), oppure fidati della subnet della rete se nessun altro container su di essa è meno affidabile del proxy: ogni container di cui ti fidi può dire a Yggdrasil da quale client arriva una richiesta.

  2. deploy/.env. Lascia gli URL https:// come sono, e aggiungi:

    Finestra del terminale
    YGG_EDGE_NETWORK=edge # la rete del proxy esterno (predefinita edge)
    YGG_EDGE_ALIAS=yggdrasil # il nome del Caddy di Yggdrasil su di essa (predefinito yggdrasil)
    YGG_TRUSTED_PROXIES=172.18.0.2 # l'indirizzo del proxy esterno su di essa, o la subnet

    Caddy ascolta sulla porta YGG_HTTP_PORT (80) di quella rete; YGG_HTTPS_PORT, YGG_BIND_ADDRESS e YGG_ACME_EMAIL non servono più, perché i certificati li ottiene il proxy esterno.

  3. Il proxy esterno. Aggiungi un blocco per ogni nome host, ciascuno dei quali passa tutto a Yggdrasil. Per un proxy Caddy:

    (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
    }

    Il terzo blocco è il sito di presentazione (YGG_SITE_URL); omettilo se non ne servi uno. Il reverse_proxy di Caddy inoltra l’header Host invariato, che serve all’host di storage (gli URL prefirmati sono firmati per esso), e imposta da sé X-Forwarded-For: da un client di cui non si fida, cioè ogni client a meno che la sua configurazione non abbia trusted_proxies, sostituisce ciò che il client ha inviato con l’indirizzo che ha ricevuto. Il Caddy di Yggdrasil legge l’header da destra e si ferma al primo indirizzo che non è un proxy fidato, quindi va bene anche un proxy che aggiunge l’indirizzo ricevuto ($proxy_add_x_forwarded_for di nginx, option forwardfor di HAProxy); non va bene uno che inoltra invariato l’header del client. Se il proxy esterno sta a sua volta dietro una CDN o un load balancer di cui si fida, aggiungi anche quegli indirizzi a YGG_TRUSTED_PROXIES, altrimenti ogni client risulta essere la CDN. Le righe header tolgono l’header Via che aggiunge il proxy esterno ed eventuali header Server, come fa il Caddy di Yggdrasil. I caricamenti verso l’host di storage arrivano a 64 MiB per richiesta: un proxy con un limite sul corpo delle richieste (client_max_body_size di nginx) deve consentirli; Caddy non ne ha.

  4. Avvia Yggdrasil, con il file di override in ogni comando docker compose (e -f deploy/compose.external-s3.yml o gli altri, secondo lo storage):

    Finestra del terminale
    docker compose -f deploy/compose.yml -f deploy/compose.behind-proxy.yml up -d --wait
  5. Ricarica il proxy esterno senza interrompere gli altri siti:

    Finestra del terminale
    docker exec <container del proxy> caddy reload --config /etc/caddy/Caddyfile

    Caddy applica la nuova configurazione senza chiudere le connessioni, e tiene la vecchia se la nuova contiene un errore. Poi ottiene i certificati per i nuovi nomi, che devono quindi puntare già alla macchina (vedi DNS). Se il Caddyfile è montato nel container come singolo file (bind mount), modificalo sul posto: un editor che salva scrivendo un nuovo file e rinominandolo sopra il vecchio (come fanno sed -i e molti editor) lascia il container a leggere il vecchio file, perché il mount segue il file originale. Scrivilo con cat nuovo-Caddyfile > Caddyfile, oppure monta la directory invece del file.

Per verificare, accedi e apri Dispositivi: la rete della tua sessione deve essere la tua, non quella del proxy esterno.

ygg-admin backup, nell’immagine dell’API, scrive un backup in una directory: il database (database.dump, nel formato custom di PostgreSQL, coerente mentre l’istanza è in funzione), poi gli oggetti del bucket (objects/, un file per oggetto) senza il prefisso exports/, poi manifest.json con l’ora, la release e il checksum del dump. Eseguito di nuovo nella stessa directory, copia solo ciò che è cambiato. Due cose non ci sono: deploy/.env e deploy/secrets/, che un ripristino richiede così com’erano; copiali tu, ora e dopo ogni modifica. Il volume del worker di esportazione non ha bisogno di backup.

Finestra del terminale
# 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

Stampa i suoi progressi e un riepilogo, ed esce con un errore se qualcosa non è riuscito. I file degli oggetti che il bucket non contiene più restano, a meno che tu non aggiunga --prune. Usa le stesse opzioni -f degli altri tuoi comandi docker compose: il comando raggiunge il database e lo storage come fa l’API, che sia RustFS, Garage o un S3 esterno.

Pianificalo e copialo fuori da questa macchina. L’unità è la directory: dopo ogni backup copiala con un qualsiasi strumento, insieme a deploy/.env e deploy/secrets/, preferibilmente uno che conservi le versioni passate (restic, borg, un bucket con versioni tramite rclone). Per esempio, ogni notte con cron dalla directory che contiene 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

Ripristina in un’istanza nuova con gli stessi deploy/.env e deploy/secrets/: avviala (così crea il bucket vuoto), ferma ciò che usa il database, ripristina, riavvia.

Finestra del terminale
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 controlla il manifest e il checksum del dump e si rifiuta finché qualcos’altro è connesso al database. Sostituisce il database con il dump e lo migra alla release in esecuzione, carica gli oggetti che mancano nel bucket, e chiude le esportazioni che erano pronte quando è stato fatto il backup: i loro archivi non ci sono, quindi risultano scadute e i loro link lo dicono. Gli amministratori esportano di nuovo se serve.

Esercitati. deploy/restore-drill.sh esegue l’intero ciclo con questi comandi su uno stack usa e getta su questa macchina (il file Compose di produzione, con TLS autofirmato su *.ygg.localhost): un progetto con un’immagine, un’annotazione e un’esportazione; un backup; lo stack distrutto con i suoi volumi e ripristinato in uno nuovo; poi nessun archivio è tornato, l’esportazione risulta scaduta, e il membro accede su un browser che contiene la sua chiave del dispositivo e apre l’immagine e l’annotazione. Richiede un checkout con pnpm install e il Chromium di Playwright (pnpm --filter @yggdrasil/web exec playwright install chromium):

Finestra del terminale
deploy/restore-drill.sh

Gli amministratori del progetto esportano una parte scelta dei loro dati come job asincrono: il loro browser concede le chiavi degli oggetti esatti a un worker di esportazione registrato dal progetto, che li decifra, li impacchetta in uno zip e lo conserva fino alla scadenza. Un progetto può registrare un worker che la sua istituzione esegue su una propria macchina (l’host non possiede mai le chiavi di quel worker; vedi il README dell’SDK dei processori, “Export worker”), oppure il worker di esportazione di questo server, parte della piattaforma e attivo per impostazione predefinita.

Si avvia con il resto dello stack (fissa YGG_EXPORT_WORKER_IMAGE con le immagini rilasciate). Al primo avvio crea le proprie chiavi nel suo volume (export-worker-state) e ne annuncia le metà pubbliche all’API con il token in deploy/secrets/export_worker_token; gli amministratori del progetto lo registrano poi dal loro progetto, e lui impacchetta le esportazioni di ogni progetto che lo ha fatto. Raggiunge solo l’API e la porta S3 dello storage, su una propria rete interna, e scrive solo nello storage delle esportazioni dell’host. I suoi archivi contano nella quota di storage di ciascun progetto.

Una release il cui worker scrive altri formati cambia il suo descrittore: dopo un aggiornamento di questo tipo, i progetti che avevano registrato la versione precedente vedono la registrazione come non aggiornata, le loro esportazioni in attesa vengono annullate, e un amministratore registra di nuovo il worker (le note di rilascio dicono quando). Come esportano gli amministratori, e cosa contiene l’archivio, è in Esportare i dati.

Non fare il backup del suo volume. Se il volume va perso, il worker crea nuove chiavi al prossimo avvio: i progetti che avevano registrato quelle vecchie vedono la registrazione come non aggiornata, le loro esportazioni in attesa vengono annullate, e un amministratore registra di nuovo il worker. Per disattivarlo, imposta YGG_EXPORT_WORKER_REPLICAS=0 in deploy/.env ed esegui docker compose -f deploy/compose.yml up -d --wait: Compose rimuove il suo container, e 0 lo mantiene disattivato negli aggiornamenti successivi. Le esportazioni già in coda su di esso allora attendono finché non torna a girare, o falliscono dopo il timeout di build, e gli amministratori vedono ancora il suo ultimo annuncio: annulla quelle esportazioni e revoca le sue registrazioni se resta disattivato. (Le versioni precedenti lo eseguivano solo con COMPOSE_PROFILES=export-worker in deploy/.env; quella riga non serve più e non ha effetto.)

Un link di esportazione (https://<app>/x/<token>) funziona senza accesso, in un browser o dalla riga di comando. Un link può contenere più parti di archivio; ognuna è uno zip completo. Un browser apre la pagina del link dell’app, che elenca le parti con le loro dimensioni e i loro SHA-256, la scadenza e questi comandi; gli amministratori copiano i link dalla pagina Esportazione del progetto.

Finestra del terminale
# 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

Anche curl -L -O sul link stesso funziona per un’esportazione in una sola parte, ma dà al file il nome del token, che è il segreto del link. Senza ?format=txt, un link a più parti risponde con un indice JSON con la dimensione e lo SHA-256 di ogni parte.

Un amministratore può proteggere un link con un codice, che invia separatamente (per telefono, o su un canale diverso da quello del link). La pagina del link allora chiede il codice; curl lo invia nell’header X-Ygg-Link-Code a ogni richiesta, inclusa quella dell’elenco degli URL (mai nell’URL, così resta fuori dai log di accesso e dalla cronologia degli URL della shell):

Finestra del terminale
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')

Senza il codice un link protetto risponde 403. Dopo alcuni codici errati in un minuto, da una stessa rete o per uno stesso link, risponde 429 fino alla fine del minuto. curl invia l’header anche all’host di storage dopo il redirect; lo storage lo ignora, e Caddy non tiene log di accesso.

L’assistente aiuta con i testi: gli amministratori di progetto descrivono un progetto e ne ottengono una bozza di configurazione, modificano una bozza su istruzione, scrivono le linee guida mancanti e traducono i testi del progetto; i membri rifiniscono il paragrafo dei metodi della pagina delle statistiche; i superamministratori riassumono le richieste di progetto; e chiunque può chiedere come fare qualcosa, con risposte tratte da questa documentazione. È disattivato per impostazione predefinita, e allora nessun testo lascia l’installazione. Per attivarlo, indica un fornitore e un modello in deploy/.env e metti la chiave API del fornitore in deploy/secrets/llm_api_key:

deploy/.env
YGG_LLM_PROVIDER=anthropic # oppure openai: qualsiasi API chat completions compatibile con OpenAI
YGG_LLM_MODEL=claude-opus-5-5
# YGG_LLM_BASE_URL=https://api.anthropic.com # per impostazione predefinita l'indirizzo del fornitore
docker compose -f deploy/compose.yml up -d api

openai comprende OpenAI, l’endpoint v1 di Azure OpenAI, OpenRouter, DeepSeek, Mistral e i modelli che esegui tu con Ollama o vLLM, che possono non richiedere una chiave (lascia il file vuoto). L’API chiama il fornitore dal proprio container, quindi un tuo server di modelli deve essere raggiungibile da lì: indica il suo indirizzo in YGG_LLM_BASE_URL (per esempio http://llm.example.org:11434/v1). YGG_LLM_TURNS_PER_DAY (200) limita le chiamate al modello di un account per giorno UTC, YGG_LLM_MAX_TURNS (16) quelle di una conversazione, YGG_LLM_MAX_TOKENS (16000) la lunghezza di una risposta e YGG_LLM_TIMEOUT_SECONDS (180) l’attesa; per i modelli di ragionamento di OpenAI e di Azure imposta YGG_LLM_TOKEN_LIMIT_FIELD=max_completion_tokens. La chiave è un file, mai una variabile; dopo averla cambiata, docker compose -f deploy/compose.yml restart api. Un’istanza installata prima dell’assistente non ha questo file, e Compose non si avvia senza: deploy/init-env.sh --missing lo scrive vuoto.

Cosa lascia l’installazione. Ciò che le persone scrivono all’assistente e il testo su cui lavora: la descrizione di un progetto e la sua bozza di configurazione (nomi, etichette, linee guida), i testi da tradurre, il paragrafo delle statistiche (dati aggregati), i campi di progetto di una richiesta (mai nome, indirizzo o istituzione del richiedente), una domanda di aiuto e pagine di questa documentazione. Mai immagini, annotazioni, chiavi o altri dati di progetto; quando un progetto cifra i propri testi, l’app chiede conferma prima di inviarli. L’API non conserva le conversazioni e non registra mai i prompt nei log. Gli account demo non possono usare l’aiuto.

La tua informativa privacy. Un fornitore esterno diventa destinatario di ciò che le persone scrivono all’assistente. L’informativa fornita dice che il servizio può usare modelli di IA; imposta YGG_LLM_PROVIDER_NAME (per esempio Anthropic) e ricostruisci l’immagine web per nominarvi il fornitore. L’informativa fornita dice anche che i dati restano nell’Unione europea: se il tuo fornitore li trasferisce altrove, pubblica la tua informativa che lo dica e imposta YGG_PRIVACY_URL. Un modello che esegui sulla tua infrastruttura non aggiunge destinatari.

L’API, il worker dei job e il worker di esportazione emettono tracce e metriche OpenTelemetry su OTLP/HTTP quando YGG_OTEL_ENDPOINT è impostato in deploy/.env; se è vuoto, non emettono nulla. L’API mantiene anche il suo endpoint Prometheus (http://api:8000/metrics, con il file metrics_token).

  • Tracce: uno span per richiesta API, con il nome del metodo e del template di route (GET /v1/projects/{project_id}) con l’ID dell’operazione e il codice di stato, e uno span figlio per istruzione del database con il suo verbo (SELECT); uno span per job in background con il nome del task, la coda e il tentativo; per il worker di esportazione, uno span per esportazione (il suo ID, la destinazione, l’esito, il numero di oggetti e di byte) con uno span per la pianificazione e uno per parte di archivio. Ogni richiesta avvia la propria traccia: l’API ignora gli header traceparent dei chiamanti.
  • Metriche: http.server.request.duration per metodo, template di route e stato; ygg.jobs.duration per task ed esito; ygg.exports.duration e ygg.exports.input_bytes per destinazione ed esito.
  • Mai al loro interno: corpi di richieste o risposte, query, header, percorsi con ID o token dei link di esportazione, argomenti dei job, messaggi di eccezione o stack trace (un errore registra solo la sua classe), nomi, dati decifrati o testo cifrato.

Lo stack include un collector facoltativo. Avvialo con il profilo otel e fai puntare i servizi a esso:

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

Riceve su una rete interna telemetry, raccoglie il /metrics dell’API con il token delle metriche e, per impostazione predefinita, registra solo un riepilogo di ciò che riceve (docker compose -f deploy/compose.yml logs otel-collector). Per inviare i dati al tuo backend di monitoraggio, copia deploy/otel/collector.yaml, aggiungi un exporter (come otlp_http con l’endpoint del tuo backend) alle sue pipeline, e imposta YGG_OTEL_COLLECTOR_CONFIG al percorso della copia. Tieni le credenziali del collector per quel backend in file che legge, non nel suo ambiente. I servizi rispettano anche le variabili standard OTEL_* (per esempio OTEL_TRACES_SAMPLER=traceidratio con OTEL_TRACES_SAMPLER_ARG=0.1) quando un override di Compose le aggiunge al loro ambiente. Un worker di esportazione gestito da un’istituzione invia a OTEL_EXPORTER_OTLP_ENDPOINT quando è installato l’extra otel (la sua immagine lo ha).

  1. Leggi le note di rilascio, e fai un backup.
  2. git fetch --tags && git checkout vX.Y.Z, poi fissa i nuovi digest delle immagini (o ricompila), ed esegui deploy/init-env.sh --missing per aggiungere i file segreti di cui la nuova release ha bisogno (lascia gli altri come sono).
  3. docker compose -f deploy/compose.yml pull (o build), poi docker compose -f deploy/compose.yml up -d --wait.

Da Garage a RustFS (ottobre 2026). RustFS ha sostituito Garage come storage incluso. Un’istanza installata prima di allora tiene i suoi file nei volumi di Garage: da ora aggiungi -f deploy/compose.garage.yml a ogni comando docker compose (init-env.sh --missing te lo ricorda). Se lo dimentichi, migrate trova un RustFS vuoto il cui bucket non ha nessuno dei file che il database elenca, si rifiuta di crearlo e si ferma, e nient’altro parte; aggiungi il file e avvia di nuovo, poi rimuovi il container RustFS creato da quell’avvio (docker compose -f deploy/compose.yml -f deploy/compose.garage.yml --profile rustfs-unused rm -sf rustfs). Per passare invece a RustFS, copia il bucket (senza exports/) da Garage a RustFS con un qualsiasi strumento S3, come rclone, prima di avviare senza il file.

migrate gira prima che partano la nuova API e il nuovo worker; le migrazioni vanno solo in avanti, quindi tornare a una release precedente significa ripristinare il backup fatto prima dell’aggiornamento. Aggiorna una release alla volta quando le note lo chiedono.

Caddy invia, sull’host dell’app:

  • una Content Security Policy che consente script solo dall’origine dell’app stessa e dall’unica import map inline (tramite il suo hash), consente la compilazione WebAssembly ('wasm-unsafe-eval', per i codec e la libreria crittografica) ma mai la valutazione di stringhe ('unsafe-eval'), permette all’app di connettersi solo a sé stessa e all’host di storage, e vieta l’inclusione in frame (frame-ancestors 'none');
  • Strict-Transport-Security per due anni, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cross-Origin-Opener-Policy e Cross-Origin-Resource-Policy: same-origin, e una Permissions-Policy che disattiva la fotocamera, la posizione e altre API del dispositivo e consente il microfono solo all’app stessa (i lettori dettano le didascalie; il browser chiede comunque a ogni lettore).

Ogni script e foglio di stile caricato dalla pagina dell’app porta un hash Subresource Integrity, e una import map elenca l’hash di ogni script che la pagina importa in seguito. I browser non controllano alcun hash per i web worker: i worker (tra cui il worker crittografico), i moduli che importano e i codec WebAssembly che scaricano sono coperti invece dal SHA256SUMS dell’immagine e dalla build riproducibile (vedi sotto). /metrics, /docs e /openapi.json dell’API non sono esposti; Prometheus raccoglie http://api:8000/metrics sulla rete di Compose con YGG_METRICS_TOKEN.

Poiché il server distribuisce l’app web, un gestore malintenzionato potrebbe modificarla. Le release rendono la cosa visibile:

  • Immagini firmate. Ogni immagine è firmata con Sigstore e porta un’attestazione di provenienza della build da GitHub Actions. Verifica entrambe rispetto al workflow di rilascio sul tag della release, non solo rispetto al 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, e gh attestation verify oci://<image>@<digest> --repo <owner>/yggdrasil4 --cert-identity con la stessa identità. Le note di rilascio riportano i comandi esatti.
  • Build riproducibili. La build di ogni immagine dipende solo dal commit, dalla versione e dall’ora del commit della release (e, per l’immagine web, dagli URL del sito di presentazione elencati nelle note di rilascio); le immagini di base, i lockfile e BuildKit sono fissati, e i timestamp dei layer sono l’ora del commit. La CI compila ogni immagine due volte da ogni commit e controlla che i digest coincidano (deploy/reproducible.sh, che elenca ogni file che differisce). Le note di rilascio elencano il digest dell’immagine per ogni piattaforma e il comando che le ricompila dal tag; confronta i digest che stampa. Più semplicemente, ricompila l’immagine web dal tag (passo 4) e confronta il suo /etc/caddy/app-SHA256SUMS con il web-app-SHA256SUMS della release: i file devono essere identici. Le immagini sono pubblicate per linux/amd64 e linux/arm64; i file dell’app web sono gli stessi su entrambe.
  • Cosa carica il browser. Gli hash di integrità in index.html devono corrispondere a quei file; un browser rifiuta uno script di pagina che non corrisponde al suo hash. I web worker, i loro import e i codec WebAssembly non portano alcun hash che un browser controlli, quindi confrontali con SHA256SUMS, che elenca ogni file della build (la build dell’immagine fallisce se uno script o un codec non vi compare).

Per una valutazione su una macchina, con dati sintetici:

Finestra del terminale
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

I servizi di sviluppo usano credenziali fisse e un catcher di posta (Mailpit su http://localhost:8025, dove arrivano i codici di accesso). Non esporli mai a una rete.

Per provare lo stack di produzione stesso su una macchina, make smoke compila le immagini, avvia deploy/compose.yml con certificati autofirmati su *.ygg.localhost ed esegue i suoi controlli.