Files
portal/deploy
..

Deploy — CTAO portal demo on strapi-experimental.cyfronet

Everything runs rootless as user strapi. Footprint on the machine: ONE directory (~/ctao-portal-demo/) + 4 unit files in ~/.config/. Uninstall restores the machine exactly (see bottom). No secrets in any file.

Two Gitea repos by design — ctao/portal (code, developed by the team) and ctao/content (Markdown + uploads, committed by the CMS). The build overlays content onto code; a push to EITHER republishes the site.

~/ctao-portal-demo/
├── gitea-data/    # Gitea state (repos, SQLite, accounts)  ← the ONLY thing worth backing up
├── gitea-config/  # Gitea app.ini (generated from env)
├── repo/          # clone of the code repo (created by first build)
├── content/       # clone of the content repo (created by first build)
├── releases/      # <code>-<content>/ dirs + `current` symlink (what nginx serves)
├── state/         # last-built release id
├── npm-cache/     # npm cache for the build container (created by build.sh)
├── bin/build.sh   # copied from deploy/ (source of truth stays in the code repo)
└── config/nginx.conf

Host prerequisites: podman, curl, jq (build.sh checks and says which is missing). Everything else runs inside containers.

Port What Exposed as
3000 Gitea https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost)
8080 portal (nginx, static) https://astro.isl-dev.grid.cyfronet.pl (ingress vhost)

Install (each step reviewed before running; [W] = writes to the machine)

  1. [W] loginctl enable-linger $USER — without lingering every user unit dies at logout and nothing starts after a reboot. Verify: loginctl show-user $USER -p Linger → Linger=yes.
  2. [W] mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user
  3. [W] Copy files from this dir (scp):
    • ctao-demo-gitea.container, ctao-demo-web.container → ~/.config/containers/systemd/
    • ctao-portal-build.service, ctao-portal-build.timer → ~/.config/systemd/user/
    • build.sh → ~/ctao-portal-demo/bin/ (chmod +x)
    • nginx.conf → ~/ctao-portal-demo/config/
    • Containerfile.build → anywhere (needed once, for the next step)
  4. [W] Pull the images at the digests pinned in the unit files (one-time, needs internet) and build the build image: podman pull docker.io/gitea/gitea@sha256:<digest from ctao-demo-gitea.container> podman pull docker.io/library/nginx@sha256:<digest from ctao-demo-web.container> podman build -t localhost/ctao-portal-build:1 -f Containerfile.build . (Upgrading later = pick new digests deliberately, update the pins in the .container files / Containerfile.build, re-pull, re-build.)
  5. [W] systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea
  6. [W] Create the Gitea admin — run interactively in a terminal so the password never lands in a file or shell history: podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password
  7. [W] In the Gitea UI: create org ctao with repos portal and content (both public read). Then, from your workstation, push both over the vhost with a repo-scoped token: git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git main
  8. [W] systemctl --user enable --now ctao-portal-build.timer — first run clones + npm ci + builds (minutes); later runs are seconds. Wait until journalctl --user -u ctao-portal-build -n 5 shows published <sha> (starting nginx earlier just serves 404s until the first build lands).
  9. [W] systemctl --user start ctao-demo-web
  10. [R] Verify: curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/ and journalctl --user -u ctao-portal-build -n 20 (shows measured build times).

CMS sign-in (Sveltia ↔ Gitea OAuth)

  • Gitea OAuth2 app (PKCE, confidential_client=false, no secret) with redirect https://astro.isl-dev.grid.cyfronet.pl/admin/; its client id is the app_id in public/admin/config.yml.
  • CORS is pinned in ctao-demo-gitea.container: ALLOW_DOMAIN takes the FULL portal origin with scheme (a bare hostname silently disables CORS in Gitea 1.27; there is no SCHEME key) and HEADERS must include Authorization or authenticated API calls from the browser fail.
  • OAuth requires a secure context: the vhosts must stay HTTPS.
  • Changing origins later = edit the quadlet env + the OAuth app's redirect URI + config.yml, then systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea.

Uninstall (leaves only podman's own storage metadata)

systemctl --user disable --now ctao-portal-build.timer
systemctl --user stop ctao-demo-web ctao-demo-gitea
rm ~/.config/containers/systemd/ctao-demo-*.container \
   ~/.config/systemd/user/ctao-portal-build.{service,timer}
systemctl --user daemon-reload
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless \
  docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine
rm -rf ~/ctao-portal-demo
loginctl disable-linger $USER   # only if nothing else of yours should survive logout

Notes

  • Only pushes to the machine's Gitea auto-deploy (code and content repos alike). Changes to deploy/* need a manual re-copy: build.sh → bin/, nginx.conf → config/ + systemctl --user restart ctao-demo-web, unit files → ~/.config/… + systemctl --user daemon-reload (+ restart). This is deliberate: the build pipeline must not execute host-side code straight from the content repo.
  • Publish latency = poll (≤10 s) + build (measured: astro build 5–7 s on the 2 vCPU VM, whole build.sh 16–21 s; every build's time lands in the journal).
  • Internet needed only for: image pulls (install) and npm ci when the lockfile changes. Routine rebuilds are fully offline.
  • Memory caps (MemoryHigh) keep us polite next to Outline + Strapi; the build container is capped at 1 GB via podman run --memory. Watch the first npm ci + build in the journal — if it OOMs inside its cgroup (contained, just retries), raise the cap.
  • Build isolation: build containers join the Gitea container's network namespace (--network=container:ctao-demo-gitea) — repo/npm code sees Gitea on localhost:3000 but cannot reach host loopback services. (Rootless netavark bridges don't work here: no ip_tables kernel module, no sudo.)
  • Secrets inventory: Gitea admin password (typed interactively, lives only in Gitea's DB) and repo-scoped push tokens (managed in Gitea) — that's the complete list. Build/poll/serve use none.