Ansible playbooks to deploy Nextcloud on a Debian 13 VM running on Proxmox VE.
The application tier is rootless Podman + Quadlet (systemd user units) running
as the lingering ncstack user: Postgres (nc-db), Valkey (nc-redis), Nextcloud
php-fpm (nc-app), an internal mTLS nginx (nc-web), plus optional Collabora Online
(rootless), ClamAV, monitoring exporters, and restic backups.
The edge is a rootful gVisor-sandboxed Caddy + Coraza WAF (edge, a system
Quadlet under runsc) terminating TLS on 80/443. It MUST run rootful: gVisor needs
rootful cgroups, and rootless runsc is non-functional on cgroup v2 (it writes the
root cgroup subtree_control / drives the system systemd manager → polkit). The edge
runs on a podman bridge — not --network host, because runsc has no working
loopback under host networking — and reaches the rootless app tier across podman
scopes via the host gateway (host.containers.internal). All inter-tier traffic
crosses an internal CA (mTLS); the app tier resolves peers by name on the
nc-internal Quadlet network.
- Proxmox VE with a Debian 13 cloud-init template (built by the sibling
proxmox-debian13-templaterepo, VMID9999); setpve_template_vmidto it. - Deploy SSH keypair at
files/ssh/nextcloud-deploy(private, git-ignored) andfiles/ssh/nextcloud-deploy.pub(committed). Generate withssh-keygen -t ed25519 -f files/ssh/nextcloud-deploy. - direnv (
direnv allow) or manuallyexport ANSIBLE_CONFIG=$PWD/ansible.cfg. The.envrcalso unsetsANSIBLE_COLLECTIONS_PATH,ANSIBLE_COLLECTIONS_PATHS, andANSIBLE_ROLES_PATH— if those are set in your shell profile they override the project config and break collection/role resolution. - Collections:
ansible-galaxy collection install -r requirements.yml - VM sizing: at least 8 GB RAM and 4 vCPUs (
pve_vm_memory: 8192,pve_vm_cores: 4). See the sizing note below — this stack does not fit in 4 GB.
Edit inventory/group_vars/all/vault.yml — it ships as a plaintext stub with
change-me-* values. Set real values for every field, then encrypt:
ansible-vault encrypt inventory/group_vars/all/vault.ymlThe preflight assert.yml in the nextcloud role refuses to run while any
change-me-* placeholder remains. The Vagrant lab is exempt because
inventory/vagrant.yml overrides every vault_* key with real lab passwords as
host vars (which outrank group_vars/all), so no change-me-* placeholder
reaches the preflight gate in the lab.
Once encrypted, every run needs --ask-vault-pass — including the Vagrant lab
run — because group_vars/all/vault.yml loads for all inventories. The lab still
uses its throwaway values from inventory/vagrant.yml. There is no .vault_pass
file and no vault_password_file in config; the password is supplied at runtime
only. (To avoid typing it while developing, keep the stub plaintext and encrypt
only before production, or pass --vault-password-file yourself.)
| Vault key | Purpose |
|---|---|
vault_pve_api_token_secret |
Proxmox API token secret (provisioning only) |
vault_nc_db_password |
Postgres password (Podman secret nc-db-password) |
vault_nc_redis_password |
Valkey password (Podman secret nc-redis-password) |
vault_nc_admin_password |
Nextcloud admin password (Podman secret nc-admin-password) |
vault_oidc_client_secret |
OIDC client secret (Podman secret oidc-client-secret) |
vault_ldap_bind_password |
LDAP bind password (Podman secret ldap-bind-password) |
vault_tls_cert / vault_tls_key |
BYO TLS material (tls_mode: byo only) |
Rootless systemd-user + linger, gVisor systrap, and rootless Collabora all require a real VM with unprivileged user namespaces enabled. A container will not work.
vagrant up
ansible-playbook -i inventory/vagrant.yml site.yml --ask-vault-passansible-playbook -i inventory/test.yml site.yml --ask-vault-passansible-playbook -i inventory/hosts.yml site.yml \
-e provision_vm_enabled=true --ask-vault-passSee tests/README.md for the full list and what each test asserts. Example:
ansible-playbook -i inventory/vagrant.yml tests/test-podman_host.yml --ask-vault-pass
ansible-playbook -i inventory/vagrant.yml tests/test-nextcloud.yml --ask-vault-pass
ansible-playbook -i inventory/vagrant.yml tests/test-collabora.yml --ask-vault-pass
ansible-playbook -i inventory/vagrant.yml tests/test-euro_office.yml --ask-vault-passRun them all in dependency order:
for t in os_base podman_host internal_pki caddy_edge \
nextcloud collabora clamav fail2ban monitoring nextcloud_backup \
firewall_coexistence; do
ansible-playbook -i inventory/vagrant.yml tests/test-$t.yml --ask-vault-pass || break
doneAll variables live in inventory/group_vars/all/main.yml (single source of truth);
role-internal tunables live in roles/<role>/defaults/main.yml.
| Variable | Default | Notes |
|---|---|---|
nc_domain |
cloud.example.com |
Public FQDN; used in certs, edge vhost, Nextcloud trusted domains |
collabora_domain |
office.example.com |
Collabora public FQDN (edge vhost) |
tls_mode |
acme |
acme = Let's Encrypt (public FQDN + 80/443 reachable); byo = operator cert/key via vault; selfsigned = lab/Vagrant |
edge_tls_profile |
intermediate |
intermediate (TLS 1.2+) or modern (1.3-only; requires edge_tls_modern_attested: true) |
waf_mode |
detection |
Coraza/CRS mode; soak in detection before enforce |
gvisor_platform |
systrap |
gVisor platform — never kvm (no nested virt on a cloned VM) |
ldap_enabled / oidc_enabled |
true / true |
LDAP(s) auth and OIDC SSO |
sse_master_key_enabled |
true |
Master-key server-side encryption (works with SSO logins) |
collabora_enabled / collabora_mode |
true / rootless |
rootless is enforced; rootful is explicit opt-in only |
clamav_enabled |
false |
ClamAV antivirus; clamav_max_scan_bytes must equal NC max upload and be <= php_memory_limit |
fail2ban_enabled |
true |
Brute-force jails in jail.d/ (coexist with base-debian's inet base_filter, see below) |
monitoring_enabled / monitoring_local_dashboard |
false / false |
Exporters on loopback; optional on-box Prometheus+Grafana |
nextcloud_backup_enabled |
false |
restic backups to nextcloud_backup_repo |
management_cidrs |
["10.0.0.0/8"] |
CIDRs allowed to reach SSH (22); also fail2ban ignoreip. Consumed by base-debian's firewall |
firewall_allow_tcp |
[80, 443] |
Public TCP ports base-debian opens for the edge |
firewall_trust_iifnames |
["podman*"] |
Interfaces base-debian trusts on INPUT (podman bridges) |
firewall_forward_policy |
accept |
base-debian FORWARD policy (container egress) |
provision_vm_enabled |
false |
Clone + configure a new VM from the Proxmox template |
pve_vm_cores / pve_vm_memory / pve_vm_disk_size |
4 / 8192 / 100G |
VM sizing — see note |
pve_validate_certs |
false |
Tolerate Proxmox's self-signed API cert; set true for CA-signed/remote PVE |
The default pve_vm_memory: 8192 (8 GB) and pve_vm_cores: 4 are a floor, not
a suggestion. Running Postgres + Valkey + Nextcloud php-fpm + nginx + the
gVisor-sandboxed Caddy/Coraza edge concurrently does not fit in 4 GB. If ClamAV
is enabled, budget another ~2.5 GB peak (clamd holds the full signature DB plus a
scan buffer sized to clamav_max_scan_bytes); if monitoring_local_dashboard is
on, add headroom for on-box Prometheus + Grafana. Do not shrink below 8 GB.
Disk: the container images alone total several GB — Collabora CODE ~1.5 GB,
Nextcloud-fpm ~300 MB, Postgres/Valkey/nginx ~250 MB, plus the edge image build
(the xcaddy Go toolchain + module cache needs ~1 GB transient). Allow at least
20 GB for the OS disk before counting user data (pve_vm_disk_size: 100G is the
default and sized for data). A small root disk (e.g. 3 GB) cannot even build the
edge image — podman build fails with no space left on device.
Full OS hardening is owned by the sibling
ansible-hardening-debian
repo and runs as a separate orchestration layer. This repo ships only a
shallow os_base. Run in this order:
provision-vm.yml(or use an existing Debian 13 host).ansible-hardening-debian:bootstrap.ymlthen itssite.yml.- This repo's
site.yml, connecting as the hardening repo's deploy user over key.
playbooks/harden.yml.disabled ships a commented-out import_playbook hook;
rename it to harden.yml and wire it once the hardening repo exposes a stable
harden.yml.
CRITICAL — the hardening layer MUST NOT break the rootless sandbox stack. Rootless Podman, gVisor systrap, and rootless Collabora all depend on unprivileged + nested user namespaces. Before/after hardening, confirm:
kernel.unprivileged_userns_clone = 1(Debian) — MUST stay enabled.user.max_user_namespaces> 0 — MUST stay non-zero.subuid/subgidranges exist forncstack.- ptrace is not blocked for the run user (gVisor systrap needs it).
- 80/443 open; 22 restricted to the
management_cidrsCIDR allowlist (base-debian opens these viafirewall_allow_tcp/management_cidrs).An over-aggressive
kernel.unprivileged_userns_clone=0(oruser.max_user_namespaces=0) breaks the entire stack and makes the Collabora rootless precondition fail closed.No layer may
flush ruleset. The firewall is base-debian's (inet base_filter); fail2ban installs its owninet f2b-tableand the app declares its open ports via thefirewall_*contract vars. A flush silently wipes every named table and every live ban. Thetests/test-firewall_coexistence.ymlgate provesbase_filtersurvives a fail2ban ban cycle.
- App tier (rootless):
nc-db,nc-redis,nc-app,nc-web, Collabora,euro-office, and the exporters run as the lingeringncstackuser (UID 1500). Their Quadlets live in/home/ncstack/.config/containers/systemd/; manage them withsystemctl --userasncstack.podman-auto-update.timerruns in user scope. - Edge (rootful): the
edgeunit is a system Quadlet in/etc/containers/systemd/; manage it withsystemctlas root. It binds 80/443 directly (rootful — no unprivileged-port sysctl needed) and runs underrunsc. - gVisor
runscis installed at/usr/local/bin/runscand registered as a Podman OCI runtime namedrunscfor both root podman (edge) and the rootless ncstack tier (euro_office Document Server). Platformsystrap, neverkvm. crun remains the rootless default. - Edge ↔ app networking: the rootful edge can't share the rootless app's podman
network, so it reaches it across scopes via the host gateway.
nc-weband Collabora are therefore published on0.0.0.0(:8443/:9980) rather than loopback — but base-debian'sbase_filterINPUT chain drops those ports from the public interface (only the internal podman bridges,iifname "podman*", may reach them, viafirewall_trust_iifnames), and the edge→nc-webhop is mTLS. DB and Redis are never published. - base-debian trusts the podman bridges in INPUT (
firewall_trust_iifnames: ["podman*"]) so rootful containers can resolve names via aardvark-dns (which listens on the bridge gateway) and the edge can reach the app tier. The public interface is never a podman bridge, so 80/443/22-only exposure still holds. - Inter-container name resolution uses netavark + aardvark-dns (installed explicitly
— only a
Recommendsof netavark on Debian). occis run aspodman exec -u www-data nc-app php occ <args>(asbecome_user: ncstack).