ci: Ansible Execution Environment + Forgejo Actions CD (+ @erwin identity) #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "erwin/execution-environment"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Reproducible Ansible deploys via a containerised Execution Environment, plus Forgejo Actions CD — so a converge stops depending on the controller's local Python/collections (the exact gap that blocked the off-LAN deploy) and can run from a runner inside the network.
What's here
execution-environment.yml(ansible-builder v3) — basecentos:stream10, ansible-core 2.18.12, pinned collections.requirements.txt/requirements.yml— pinned EE python + galaxy deps.ansible-navigator.yml,Makefile(ee-build/galaxy/lint/check/deploy)..forgejo/workflows/deploy.yml— build EE +ansible-navigator run site.ymlon push/dispatch via a self-hosted runner; posts converge status as @erwin.docs/execution-environment.md— build/run/CD + runner prerequisites.docs/erwin.md— who @erwin is (powered by Claude / Claude Code, active since Jun/2026).Before relying on CD
make ee-buildonce.ANSIBLE_VAULT_PASSWORD,SSH_PRIVATE_KEY,REGISTRY_USERNAME,REGISTRY_TOKEN,ERWIN_TOKEN.self-hostedrunner with podman + WG/SSH reachability.— @erwin
🤖 Generated with Claude Code
Second libvirt VM on the hypervisor, parallel to gierz-services. Hosts Flavia's stack — Synapse + Element Web + GoToSocial for the höring.eu (xn--hring-jua.eu) domain. Single-instance roles run unchanged; per-tenant identity comes from host_vars. Naming: ASCII alias `hoering-services` (umlaut would break vault-key derivation and other tools). User-facing identifiers (DNS, MXIDs, fedi handles) keep `höring.eu`. Scaffolding only — nothing live yet: - tofu/hoering-services/{main,versions}.tf — 3 GiB / 2 vCPU / 50 GiB data volume. MAC 52:54:00:a0:7e:40 → 192.168.122.40. - cloud-init/hoering-services/{meta-data,user-data.j2} — references vault_hoering_services_ssh_host_keys (to be generated). - playbooks/render-cloud-init.yml — adds the new VM to the render loop. - roles/libvirt_host/files/default-network.xml — static-DHCP reservation. - inventory/hosts.yml — `hoering-services` host + `hoering` per-tenant child group under `services:`. - inventory/host_vars/hoering-services.yml — services_vm_base config + restic paths for the three tenant services. Per-service identity vars documented as comments; filled in when each role is wired up. Co-Authored-By: LLM <noreply@invalid>Three Quadlets (server + postgres + redis/valkey) on a per-service podman network. No ML container — face/object recognition off; add immich-machine-learning later if smart search is wanted. Storage lives on the services VM data volume at /srv/services/state/immich/library. Grow vdb when phone libraries fill it ("virsh blockresize services vdb <N>G" → "xfs_growfs /srv/services" in guest). OIDC configured in Immich's admin UI on first run — env-based OAuth was removed upstream around v1.110. The client_secret still lives on-host via machine_credential at /etc/authentik/oidc/immich-client-secret so the UI value is reproducible across rebuilds. Pattern mirrors the gotosocial role: machine_credential for the postgres password, render-immich-secrets.sh ExecStartPre composes /run/immich/*-secrets.env at every container start, non-secret config in /etc/immich/{server,postgres}.env. Code-only — DNS, Caddy vhosts on edge+gateway, Authentik application, and restic backup paths land in follow-up commits. Co-Authored-By: LLM <noreply@invalid>Edge gets TLS termination + apex Matrix/WebFinger delegation; gateway gets the http:// upstream blocks pointing at hoering-services (192.168.122.40). Site labels use punycode (xn--hring-jua.eu) because browsers send the A-label as SNI for umlaut hostnames and Caddy needs exact-match for cert issuance + routing — the umlaut in the address bar is purely a UI render. Edge: - xn--hring-jua.eu apex + www: serves /.well-known/matrix/{server,client} (federation handle is @flavia:xn--hring-jua.eu, no MAS in this tenant), redirects /.well-known/webfinger to social. so the JSON comes from one place, otherwise 302 to element. - matrix., element., social.: TLS-terminate + proxy over WG. Gateway: - matrix./element./social.xn--hring-jua.eu → 192.168.122.40:{8008,8083,8081} (default per-role ports, since hoering-services runs single-instance roles, not a multi-tenant refactor). Nothing wires up until hoering-services is provisioned + the synapse/ element_web/gotosocial playbooks run against it (task #5, #6). Co-Authored-By: LLM <noreply@invalid>Single-node Garage at: - s3.findorff.gierz.eu — S3 API (port 3900) - garage.findorff.gierz.eu — S3-website endpoint (port 3902, apex only) Storage on a dedicated qcow2 (vdc, 200 GiB, in the libvirt data pool) mounted at /srv/garage. Isolated from /srv/services so a fill on S3 buckets can't strangle Postgres / git repos. Role pattern mirrors gotosocial/immich: - machine_credential for rpc_secret (32-byte hex) - render-garage-secrets.sh ExecStartPre substitutes rpc_secret into /run/garage/garage.toml at every container start; /etc copy is the source with an empty secret, never read by the container - Quadlet mounts the runtime config and the storage dirs - admin port (3903) bound 127.0.0.1 only — podman-exec access only Includes the format+mount task (XFS, label=grg) gated on a blank vdc so re-runs are no-ops. cloud-init's fs_setup learns vdc too for future rebuilds. Restic backs up /etc/garage and the meta/ catalog only — the data/ tree IS the backup destination for everything else, backing it up here would be circular. Bootstrap (one-time, after first converge — see role's post-install debug message): podman exec garage /garage status # find NODE_ID podman exec garage /garage layout assign -z dc1 -c 100G <NODE_ID> podman exec garage /garage layout apply --version 1 podman exec garage /garage key create <key> podman exec garage /garage bucket create <bucket> podman exec garage /garage bucket allow --read --write --owner <bucket> --key <key> Wildcards under garage.findorff.gierz.eu are NOT exposed — that needs DNS-01 ACME and a CF API token, deferred until per-bucket web hosting actually wanted. Code-only — VM disk attachment is `tofu apply`, layout-apply is `podman exec`. Co-Authored-By: LLM <noreply@invalid>Two Quadlets (app + postgres) on a per-service podman network. Pattern mirrors gotosocial/immich/garage: - machine_credential for postgres-password + Django SECRET_KEY - render-tandoor-secrets.sh ExecStartPre composes /run/tandoor/*- secrets.env at every container start - non-secret config in /etc/tandoor/{tandoor,postgres}.env - signup off (ENABLE_SIGNUP=0) — all logins via Authentik Plumbing: - cloudflare_dns: recipes.findorff A + AAAA on the edge IPs. - Caddy edge: TLS-terminate + 100 MB body limit (recipe photos, no video). Gateway: http:// upstream to 192.168.122.30:8086. - authentik_oidc_apps: `tandoor` slug. Redirect URI is the django- allauth openid_connect callback path. - unified_portal: tile under the services group. - gierz-services restic: back up postgres + mediafiles + /etc; exclude staticfiles (regenerable from image). Two manual bootstrap steps (documented in the role's post-install debug + the playbook comment): 1. `manage.py createsuperuser` in the container. 2. /admin/socialaccount/socialapp/ → Add the OpenID Connect provider, paste the client_secret from /etc/authentik/oidc/tandoor-client-secret. Tandoor doesn't expose SOCIALACCOUNT_PROVIDERS as an env var, so the SocialApp record can't be declared from ansible the way immich's config.yaml can. Same constraint shows up in any django-allauth deployment — accepted, not a hack. Co-Authored-By: LLM <noreply@invalid>Two real problems land in one fix: 1. **Grafana white page / no metrics — root cause: no datasources.** The role's README has this as TODO. Grafana boots clean, the SQLite has empty datasources table, the UI renders white because every dashboard query targets a datasource that doesn't exist. Add templates/grafana-datasources.yaml.j2 (Prometheus + Loki resolving each other by container name on the shared observability network), render it to /etc/observability/grafana-provisioning/datasources/ default.yaml, mount the whole provisioning tree into the container at /etc/grafana/provisioning. UID-based, so re-rendering doesn't duplicate. 2. **Loki had no log shipper.** Container was running and reachable but nothing was pushing logs in. New `alloy` role (Grafana Alloy v1.10 — replaces Promtail as of Loki 3.x): - Single Quadlet on the observability network so it resolves `loki` by name when co-located on services VM. - Reads /run/log/journal + /var/log/journal (RO bind-mounts). - discovery.relabel hoists __journal__systemd_unit → unit, __journal__hostname → host, __journal_priority → priority so LogQL queries can filter by service / severity. - Persistent journal enforced (creates /var/log/journal with systemd-journal group — journald auto-switches to persistent storage when the dir exists). - Off-box hosts (edge, gateway, gatekeeper, hoering-services) apply the role with an override pointing alloy_loki_endpoint at gierz-services:3100/loki/api/v1/push. After convergence: white page goes away (Prometheus + Loki appear as selectable datasources), and Explore → Loki shows journald entries from the services VM in real time. Co-Authored-By: LLM <noreply@invalid>Two pieces: 1. Headscale log.format text → json. LogQL queries get structured fields free: `{unit="headscale.service"} | json | node_name="..."`. Raw journalctl is uglier but Grafana renders structured logs well. 2. Alloy on gateway, gatekeeper, hypervisor, hoering-services (when the VM lands). Refactor alloy.container to support both network modes: - co-located (services VM): Network=observability, resolves `loki` by container name. - off-box: Network=host, ships to http://192.168.122.30:3100/loki/api/v1/push. Per-host overrides in host_vars set alloy_network_name=host and the loki endpoint. New playbooks/alloy.yml targets the four off-box hosts in one converge. observability role opens Loki's host port 3100 in firewalld so the off-box shippers can ingest. Loki has auth_enabled=false; access restricted by firewalld + libvirt subnet boundary. Edge (Hetzner) skipped intentionally — edge → services routes via the WG tunnel and isn't trivially symmetric. Public-internet auth logs are interesting; revisit when we want them in Loki. After convergence: every libvirt VM's systemd journal lands in Loki with stable labels (instance, unit, host, priority). SSH attempts, service restarts, kernel events, every podman container's stdout — all queryable from one Grafana Explore tab. Co-Authored-By: LLM <noreply@invalid>Wire up the alerting foundation. Three new pieces on the services VM, all on the existing observability podman network: - Alertmanager (port 9093) — routes via ntfy's built-in `alertmanager` X-Template (no bridge container). Two routes: warning → topic homelab-alerts priority 3, critical → same topic priority 5 with rotating_light tag. 12h repeat for warnings, 4h for critical. Inhibits warnings while critical for same instance fires. - Blackbox exporter (port 9115, host-localhost) — single http_2xx module probes 13 public hostnames; metric probe_ssl_earliest_cert_expiry feeds the cert-expiry rules. - Prometheus alert rules in roles/observability/files/rules/: - system.yml: NodeDiskFill{Warning,Critical} (85%/95% root + /srv), NodeMemoryPressure (90% for 10m), NodeDown (up==0 for 5m), ContainerCrashloop (>3 'activating' transitions in 5m). - services.yml: TLSCertExpiring{Soon,Critical} (14d / 3d), CaddyHigh5xx (>5% for 10m), SynapseFederationQueueBackedUp (>1000 pending PDUs for 15m), HeadscaleNodeOffline (>24h). Plumbing: - prometheus.yml.j2 rewritten to support per-scrape metrics_path, scheme, bearer_token_file, basic_auth, params, relabel_configs — needed for the blackbox indirection pattern (targets are URLs to probe; __address__ relabel sends the request to blackbox-exporter). Adds rule_files glob + alerting.alertmanagers stanza. - prometheus.container mounts /etc/prometheus/rules read-only. - Default scrape configs add alertmanager + the blackbox_http job (probes the public hostname list in observability_blackbox_probe_targets). - Firewalld opens 9093. - Caddy edge + gateway vhosts for alertmanager.findorff.gierz.eu (DNS + reverse_proxy). UI is raw for now; forward_auth via Authentik to follow when the unified portal pattern is generalised. Manual one-time after first converge: - Create vault_ntfy_alerts_token (ntfy access token with publish-only ACL on `homelab-alerts`). Empty default works against vanilla anonymous-permissive ntfy; tighten when ntfy ACL is set. - Subscribe to the `homelab-alerts` topic on phone for push. Deferred (TODO in defaults + services.yml comments): - Restic exporter sidecar — needs SSH-to-hydrogen creds wired into a third container; ResticBackupStale rule commented out until then. - Postgres exporter sidecars per DB-bearing service — separate commit. Co-Authored-By: LLM <noreply@invalid>Enable Prometheus metrics on every service that exposes them, add scrape entries in gierz-services host_vars, open the new ports in firewalld where needed. Per-service changes: - synapse: add `type: metrics` listener on 9008 + enable_metrics:true in homeserver.yaml. PublishPort 9008. Scrape path /_synapse/metrics. - headscale: flip metrics_listen_addr 127.0.0.1→0.0.0.0:9090, publish on host:9098. Firewall opens 9098 alongside 8089. - gotosocial: add metrics-enabled:true (auth off) to config.yaml. Endpoint on the main HTTP port (8081/metrics). - garage: admin api_bind_addr 127.0.0.1→[::]:3903 so /metrics is reachable; PublishPort 127.0.0.1:3903 (host-localhost only — admin API has destructive verbs, don't expose to LAN). - forgejo: FORGEJO__metrics__ENABLED=true. Token-less; relies on firewalld + libvirt boundary for access control. - authentik: PublishPort 9301:9300 on server, 9302:9300 on worker (since 2025.8 task metrics moved server → worker). Firewall opens both alongside the main HTTP port. - caddy (gateway): global `servers { metrics }` + bare `:9019 { metrics }` site block. Gated on caddy_metrics_port_enabled in host_vars (true for gierz-gateway). Firewall opens 9019. Scrape config in gierz-services host_vars covers all of the above plus node_exporter on gierz-gateway (192.168.122.10:9100) and the blackbox_http indirection job (probes the public hostname list via blackbox-exporter, with the standard __address__ relabel pattern). Edge Caddy intentionally skipped — services-VM→edge routing isn't symmetric (no WG mesh, only edge→gateway). Revisit if/when that changes. Co-Authored-By: LLM <noreply@invalid>Drop the blank-Grafana-after-login problem. Five dashboards fetched from upstream at converge: - node-exporter-full (grafana.com/1860 r45) — canonical system view (CPU/mem/disk/net) for every node_exporter target. - logging-loki-v3 (grafana.com/24574 r2) — Loki log volume by container/host/severity with regex exclusions. - caddy-monitoring (grafana.com/22806 r1) — Prometheus + Loki pane for request rate / upstream health (gateway Caddy now exposes metrics on :9019). - forgejo-gitea (grafana.com/17802 r3) — git forge counters. - synapse (raw.githubusercontent / element-hq/synapse master) — canonical homeserver dashboard, kept in lockstep with Synapse. Pattern: - grafana-dashboards-provider.yaml.j2 — provisioning provider that watches /etc/grafana/dashboards/ every 30s; allowUiUpdates so experiments stick in SQLite but converges re-pin canonical JSON. - get_url fetches each dashboard to host's dashboards dir with force:false (URL pin = revision pin, so re-fetch only happens when defaults are bumped). - grafana.container mounts dashboards dir read-only at the path the provider yaml expects. Deferred: - postgres-overview (grafana.com/14114) — needs postgres-exporter sidecar that doesn't exist yet. - cadvisor (grafana.com/19792) — same, no cAdvisor running. - garage — no community dashboard; vendoring from source deferred. Closes the "Grafana is wired but useless" gap. After convergence: log in, every dashboard renders, every panel populates from the existing Prometheus + Loki datasources. Co-Authored-By: LLM <noreply@invalid>Third libvirt VM on the hypervisor (after gierz-services and hoering-services). Hosts the family "Drive" — Nextcloud for files + CalDAV + CardDAV, OnlyOffice Document Server for collaborative docx/xlsx editing. Separate VM because adding both to gierz-services would put it under memory pressure (Authentik + Synapse + the rest already use ~4 GiB of 6 GiB; Nextcloud + OnlyOffice add ~3.5 GiB). Scaffolding only — nothing live yet: - tofu/gierz-cloud/{main,versions}.tf — 4 GiB / 2 vCPU / 100 GiB data volume (vdb). MAC 52:54:00:c1:0d:50 → 192.168.122.50. - cloud-init/gierz-cloud/{meta-data,user-data.j2} — references vault_gierz_cloud_ssh_host_keys (to be generated, same workflow as the other VMs). - playbooks/render-cloud-init.yml — extends the render loop. - libvirt_host: DHCP reservation for the new MAC. - inventory/hosts.yml — `gierz-cloud` host + new `cloud` group under `services:` so per-role playbooks can target it cleanly. - inventory/host_vars/gierz-cloud.yml — services_vm_base config (data volume at /srv/cloud), restic paths for nextcloud + onlyoffice, alloy log shipper override. Roles (nextcloud, onlyoffice) + DNS + Caddy land in the next commit. Co-Authored-By: LLM <noreply@invalid>Three small services on gierz-services VM, all roughly the same shape as gotosocial/tandoor/immich. Combined ~380 MB RAM. Workout-Tracker (fit.findorff.gierz.eu): - jovandeginste/workout-tracker, single Go binary, SQLite. - Imports GPX/FIT, strength + cardio + PRs, multi-user. - No native OIDC upstream; local accounts only. forward_auth via Authentik is a future Caddy enhancement. - machine_credential for the JWT session-cookie key. HortusFox (garden.findorff.gierz.eu): - danielbrendel/hortusfox-web, plant inventory + watering schedules + photo log, purpose-built family garden tracker. - Two Quadlets: app + MariaDB (upstream constraint — first MariaDB in the homelab; rest are Postgres). - machine_credential for mariadb root + user + bootstrap admin. - No native OIDC; local accounts. Actual Budget (money.findorff.gierz.eu): - actualbudget/actual-server, YNAB-style envelope budgeting, SQLite per budget file. GoCardless bank import works for German banks. - Native OIDC via ACTUAL_OPENID_* env vars (confirmed against actualbudget.org/docs/config/oauth-auth/). authentik_oidc_apps entry `actualbudget` declared; client_secret injected at start via render-actualbudget-secrets.sh from authentik's machine_credential path. Password login kept as fallback (ACTUAL_OPENID_ENFORCE=false). Plumbing: - cloudflare_dns: fit + garden + money subdomains. - Caddy edge: TLS-terminate for all three. - Caddy gateway: http:// upstreams to 192.168.122.30:{8090,8091,8092}. - unified_portal: tiles for all three. - services.yml: new "Family quality-of-life" section. - gierz-services restic: backup paths for all three (DBs + media). Bootstrap manual steps post-converge: - Workout-Tracker: visit fit.findorff.gierz.eu, register first user (becomes admin), then ENABLE the registration_disabled flip in workout-tracker.env if you want others to self-register. - HortusFox: visit garden.findorff.gierz.eu, log in as paul@gierz.eu / admin-password from /etc/hortusfox/credentials/admin-password. - Actual Budget: visit money.findorff.gierz.eu, click "Login with OpenID", Authentik handles. ACTUAL_USER_CREATION_MODE=login auto-provisions on first SSO landing. Co-Authored-By: LLM <noreply@invalid>All four would have surfaced on first converge or in silently-broken alerts. 1. prometheus.yml template: indent(6) → indent(6, first=True) on the params + relabel_configs blocks. Jinja's indent default leaves the first line at column 0, breaking YAML structure. Prometheus would refuse to load. 2. blackbox scrape targets: inline the URL list instead of targets: "{{ observability_blackbox_probe_targets }}" — Jinja- in-quoted-string list expansion via Ansible auto-unsafe is fragile and sometimes returns the stringified list literal. One source of truth now lives in gierz-services host_vars; the defaults entry stays for documentation only. 3. ContainerCrashloop: increase(...) → changes(...) and target the {state="active"} gauge instead of {state="activating"}. increase() only works on counters; the systemd-state metric is a gauge that flaps between 0 and 1. changes() counts how many times the value flipped. Threshold raised to 6 (each restart = down→up = 2 changes, so >6 ≈ >3 restarts in 5m). 4. CaddyHigh5xx → CaddyHighErrorRate: caddy_http_requests_total has no `code` label (verified against Caddy v2 docs — labels are server/handler/method). Original query matched zero series. Rewritten as caddy_http_request_errors_total / caddy_http_requests_total ratio — the idiomatic Caddy way to detect upstream/connection failures. Untouched but still real risks (lower priority — won't break convergence, just may not fire as expected): - synapse_federation_send_queue_pending_pdus — verify metric name by curling /_synapse/metrics after first converge. - headscale_node_last_seen — same. Headscale's metric naming has shifted across versions. Co-Authored-By: LLM <noreply@invalid>The family-QoL + paperless + jellyfin + hedgedoc + jupyter + overleaf roll-out tips the existing 6 GiB allocation into OOM territory: - existing (authentik, synapse, mas, forgejo, vault, ntfy, …): ~3.5 GB - already-queued (immich, garage, tandoor, WT, hortusfox, AB, paperless, grocy, homebox): ~2.2 GB - new this sprint (jellyfin, hedgedoc, uptime-kuma, jupyter, overleaf): ~1.7 GB - total ≈ 7.5 GB before headroom. 6 GiB would OOM the heaviest container (likely Authentik or Overleaf); 10 GiB leaves real breathing room. Live `tofu apply` against tofu/services requires a VM shutdown to re-enumerate memory. Schedule for a quiet window or accept a few minutes of downtime. Future: if memory pressure shows up again, the right answer is more VMs (a gierz-compute for jupyter+overleaf, a gierz-media for jellyfin) rather than another bump. Co-Authored-By: LLM <noreply@invalid>Three more services on services VM. Combined ~500 MB RAM. Jellyfin (media.findorff.gierz.eu): - Single Quadlet, SQLite. Auto-creates /media/{movies,shows,music, audiobooks,books} subdirs; point Jellyfin libraries at these via the first-run wizard. Media tree NOT in restic (re-rippable, huge). - Local accounts; per-user PINs work great for kids on the TV. - WebSocket support automatic in the Caddy reverse_proxy. Hedgedoc (notes.findorff.gierz.eu): - App + Postgres on per-service network. Real-time collaborative markdown. Family + homelab runbook in one place. - Native OIDC via CMD_OAUTH2_* env vars — Authentik's auth/token/ userinfo URLs are shared across applications (only client_id differs), hardcoded in defaults. - Email signup off; OIDC only. Anonymous edits ON for shareable notes. - machine_credential for postgres password + session secret. Uptime Kuma (status.findorff.gierz.eu): - Single Quadlet, SQLite, no sidecars. 1.x stable (2.x still beta). - Configure monitors via UI first-run; status page is a separate view it generates. Plumbing: - cloudflare_dns: media + notes + status (+ jupyter + tex DNS pre-staged for the next commit). - Caddy edge + gateway vhosts for all five forward-looking entries. - authentik_oidc_apps: hedgedoc slug. - unified_portal: tiles for all (including jupyter + tex stubs). - services.yml: extends QoL section with all five roles (jupyter + overleaf to land next). - gierz-services restic paths + cache excludes. Bootstrap manual: - Jellyfin: visit media.…, run setup wizard, create admin, point libraries at /media/movies etc. - Hedgedoc: visit notes.…, sign in with Authentik (first OIDC login creates the user). - Uptime Kuma: visit status.…, register first user (admin), add monitors for each homelab endpoint. Co-Authored-By: LLM <noreply@invalid>JupyterLab (jupyter.findorff.gierz.eu): - Single container, jupyter/scipy-notebook image (numpy/scipy/pandas/ matplotlib/sklearn pre-installed). Single-user — full JupyterHub with DockerSpawner against the Podman socket is non-trivial; this is the pragmatic substitute. Swap to a jupyterhub role if/when multi-user matters. - Token auth via machine_credential (access-token persists across restarts). Visit jupyter.…, paste token from /etc/jupyter/credentials/access-token on the VM. - Work dir mount at /home/jovyan/work — survives image upgrades. Overleaf CE (tex.findorff.gierz.eu): - Three Quadlets: sharelatex (app) + mongo + redis/valkey. Mongo replica-set required since v5 even single-node — rs.initiate() is a one-time manual step (see role's post-install debug). - CE limits: NO OIDC (Pro-only), no Git bridge, no track-changes. Local accounts. Sufficient for solo + spouse paper writing. - Bootstrap admin via `grunt user:create-admin` after first start. - TimeoutStartSec=600 — first start does apt-get + LaTeX-package install inside the image, takes minutes. Resource caveats: - Overleaf is the heaviest service in the stack (~1.5 GB RAM at rest, ~3 GB image). Bumped services-VM RAM to 10 GiB in prior commit anticipated this. - Jupyter scipy-notebook image is ~1.5 GB. Pulls slow on first deploy. Both wired into services.yml, Caddy (already-staged), DNS (already-staged), unified_portal (already-staged), restic backup paths (already-staged in inventory). Drop-in completion. Bootstrap manual: - Jupyter: visit jupyter.…, paste token. First notebook in /home/jovyan/work persists. - Overleaf: 1. `podman exec overleaf-mongo mongosh --eval 'rs.initiate(...)'` 2. `systemctl restart overleaf` 3. `podman exec overleaf grunt user:create-admin --email=paul@gierz.eu` 4. Visit the printed one-time URL, set password, invite others. Co-Authored-By: LLM <noreply@invalid>6d36eb0e0ctoe7f0483201View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.