actions/cache -- and actions/setup-go, which builds on it -- packs its archive with zstdmt when zstd is present and falls back to single-threaded gzip when it is not. The archive name says which happened: cache.tzst against cache.tgz. Measured in l.kirchner/patchmgr, CI run 1957. The cache is GOMODCACHE plus GOCACHE and runs to 2-5 GB. runner-gpu has zstd and writes cache.tzst; runner-01, -02 and -03 write cache.tgz, and go test -race (stable) spent 605 seconds packing it for a job with 41 seconds of work. Across all eight jobs of that run, 2312 of 3146 seconds went into this step. This does not settle whether the cache is wanted at all -- in host mode with a persistent home both directories survive between jobs anyway, and patchmgr is switching it off for that reason. But as long as any repository on the instance uses it, it should not be compressed on one core. zstd is a package for the post step, not for jobs, so it sits with the others rather than in a workflow: this runner installs nothing at job time, by design.
luki-net / proxmox-scripts
Community-script-style installers for LXC services in the luki-net Proxmox VE homelab. Each script creates an unprivileged Debian 12 LXC and installs one specific app — sensible defaults, interactive prompts, env-overridable for non-interactive runs.
Inspired by community-scripts/ProxmoxVE, but minimal, self-hosted and tailored to this stack.
Script catalog
| App | What it provisions | Status |
|---|---|---|
| devpi | Private PyPI cache/mirror — saves time on CUDA/torch rebuilds | ✅ stable |
| webapp | Next.js site with deploy-as-code via a repo-scoped Gitea Actions runner (host mode, no inbound port) | ✅ stable |
| nexus | App LXC for nexus (Family Knowledge Hub): host-mode runner (label nexus, CI + deploy), service skeleton, /opt/nexus layout. Runtime is provisioned/extended via install/nexus-runtime.sh (idempotent, re-runnable) |
✅ in production |
| nexus-db | PostgreSQL 16 + pgvector for nexus — least-privilege role, pg_hba allowlist (only the nexus LXC), DSN handed over via credentials file | ✅ in production |
| authentik | Central homelab IdP (official Docker Compose via LXC nesting) — headless bootstrap admin and API token for agent-driven blueprint configuration, blueprints mount, permanent auth domain (WebAuthn RP-ID) | ✅ in production |
| runner | General instance-wide Gitea Actions runner (label homelab) — Docker for throwaway CI test containers, deliberately no sudoers/deploy rights |
✅ in production |
Run any one-liner on a Proxmox VE host as root:
bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/<app>.sh)"
Usage
Interactive prompts cover CTID, hostname, disk, RAM, CPU, bridge/VLAN, storage, IP/gateway/DNS — plus app-specific values. Defaults are sane. Non-interactive override via env vars:
CTID=200 HOSTNAME=devpi DISK_SIZE=30 RAM=4096 CORES=4 IPCFG=dhcp \
bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/devpi.sh)"
All defaults (DEFAULT_HOSTNAME, DEFAULT_DISK, …) and app config values are settable per call via env vars.
Every input is validated (digits-only for CTID/disk/cores/RAM/VLAN, IP/CIDR/gateway format, hostname/storage charsets). Invalid interactive input re-prompts — including pasted values with invisible control/non-ASCII bytes, which are rejected rather than silently stripped. Env-provided values are validated too and abort the run when malformed (no re-prompt loop in non-interactive use).
The pattern
Two files per app, both sourcing the shared libs via curl:
ct/<app>.shruns on the PVE host: prompts → unprivileged LXC → pushes a config env file into the container → bootstraps the installer. Apps that need Docker (authentik, runner) enablenesting+keyctlautomatically. The standard prompts include an SSH root login choice (SSH_ROOT_LOGIN, default yes for homelab convenience;nokeeps the Debian key-only default) — applied inside the container as an sshd drop-in byconfigure_ssh_root_login.install/<app>-install.shruns inside the LXC: packages, unprivileged app user, secrets generated on-host (never printed), systemd units, a/root/<app>.credentialsnotes file — then shreds the bootstrap env. Idempotent where it matters: re-runs skip what exists.setup_base_aptalso fixes the bare-template locale situation:C.UTF-8is exported up front (glibc built-in, covers the first apt run without perl warnings), thenen_US.UTF-8is generated and set as the system default.
DB installers carry one extra rule: a PostgreSQL cluster/database freezes its encoding at initdb / CREATE DATABASE time and it can never be changed afterwards. A C (non-UTF-8) locale yields a SQL_ASCII cluster — psycopg3 then hands text back as bytes and SQLAlchemy crashes. So the pattern (helpers ensure_utf8_locale_active + assert_db_encoding_utf8 in lib/install.func) is: make a UTF-8 locale active before the server package runs initdb, create the database explicitly with TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE/LC_CTYPE 'en_US.UTF-8' (never inherit the cluster default), and verify pg_encoding_to_char returns UTF8 before finishing — a wrong encoding aborts the install (it's DB damage, see wiki → Lessons). Maintenance examples use su - postgres -c …, not sudo — these minimal LXCs have no sudo.
Shared libs: lib/build.func (host-side: prompts, LXC create, bootstrap) and lib/install.func (in-container: apt, users, systemd, http-wait).
Security conventions
- Unprivileged LXCs only; app processes run as dedicated system users.
- Least privilege everywhere: narrow sudoers (exact-match commands — sudoers compares verbatim incl. arguments), DB allowlists, LAN-only binds for inference ports.
- Secrets are generated on the target host and live in
0600files — never in the repo, the wiki or chat logs. - Runner separation: the nexus runner is repo-scoped and lives on the production LXC because it holds deploy rights; the general
runnerLXC is instance-wide because it holds none. Don't mix these scopes.
CIS Tier-A hardening
install/cis-tierA.sh applies the Tier-A baseline to an existing Debian 12 LXC: SSH drop-in, login.defs aging + YESCRYPT, pwquality/faillock, PAM (pwquality, pwhistory, faillock, nullok removed) and auditd rules. It is idempotent, backs everything up to /root/cis-hardening-backup-<ts>/, gates the SSH restart on sshd -t and rolls PAM back if a referenced module is missing.
pct push <id> install/cis-tierA.sh /root/cis-tierA.sh
pct exec <id> -- bash /root/cis-tierA.sh
Applied to authentik first (2026-07-24), then to 11 further containers. The PVE hosts and the Wazuh manager VM stay unhardened on purpose — they are the break-glass foundation.
Two things worth knowing before touching it:
- Section 1 rewrites
99-cis-hardening.confwholesale on every run. Host-specific exceptions belong in a separate drop-in that sorts after it (e.g.99-zz-local-forward.conf), never in that file. AMatchblock extends until the nextMatch— acrossIncludefile boundaries — so such a drop-in has to stay alphabetically last. - Forwarding is denied per option, not via
DisableForwarding. Both are equivalent in effect, butDisableForwardingoverrides every other forwarding option and does not appear insshd -Toutput. A denied port-forward then reportsadministratively prohibitedwhilesshd -Tcheerfully claimsallowtcpforwarding yes, which costs hours to diagnose.
Caveat: auditd is a no-op in unprivileged LXCs (the host owns the audit subsystem); the script probes for it and removes the package again if it cannot load rules. Together with the Section-1 partition/kernel checks that are equally N/A in a container, the achievable SCA score stays well below 100 %.
Contributing
All changes go through a pull request with cross-review (Claude Code ↔ Codex, or a human) — no direct pushes to main. This rule exists because of two real incidents: a pasted VLAN tag carrying an invisible non-UTF-8 byte broke pct create mid-run, and the "missing source build.func" bug shipped twice — caught in review on the nexus-db PR, but reaching production via an un-reviewed authentik commit (see wiki → Lessons).
CI (.gitea/workflows/ci.yml, instance-wide homelab runner from ct/runner.sh) enforces on every PR: bash -n over all scripts, the "every ct/*.sh sources build.func" check (tests/check_ct_source.sh) and the validation unit tests (tests/test_validation.sh); shellcheck runs when available on the runner.
Build new app prompts on prompt_validated/require_valid from lib/build.func instead of bare read. How to add a script: docs/adding-a-script.md.
Repo layout
.
├── ct/ # Host-side scripts, one per app
├── install/ # In-container installers (+ nexus-runtime.sh re-provisioner,
│ # cis-tierA.sh hardening pass)
├── lib/
│ ├── build.func # Shared host-side helpers (prompts, LXC create, bootstrap)
│ └── install.func # Shared in-container helpers (apt, systemd, users, http-wait)
├── tests/
│ ├── test_validation.sh # Unit tests for the input validation helpers
│ └── check_ct_source.sh # Every ct/*.sh must source build.func
├── docs/
│ └── adding-a-script.md
├── README.md
└── LICENSE
Related
- nexus-hub — Family Knowledge Hub (main consumer of nexus/nexus-db/authentik/runner)
- Wiki — per-app runbook pointers, conventions, lessons learned
License
MIT