Skip to content

Repository files navigation

ansible-nextcloud-debian

Ansible Debian Nextcloud Podman gVisor + Coraza WAF Proxmox VE Tested with Vagrant

License: MIT Last commit Repo size GitLab mirror

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.


Prerequisites

  • Proxmox VE with a Debian 13 cloud-init template (built by the sibling proxmox-debian13-template repo, VMID 9999); set pve_template_vmid to it.
  • Deploy SSH keypair at files/ssh/nextcloud-deploy (private, git-ignored) and files/ssh/nextcloud-deploy.pub (committed). Generate with ssh-keygen -t ed25519 -f files/ssh/nextcloud-deploy.
  • direnv (direnv allow) or manually export ANSIBLE_CONFIG=$PWD/ansible.cfg. The .envrc also unsets ANSIBLE_COLLECTIONS_PATH, ANSIBLE_COLLECTIONS_PATHS, and ANSIBLE_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.

Secrets

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.yml

The 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)

Run matrix

Local Vagrant (libvirt)

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-pass

Remote test VM

ansible-playbook -i inventory/test.yml site.yml --ask-vault-pass

Production (including VM creation on Proxmox)

ansible-playbook -i inventory/hosts.yml site.yml \
  -e provision_vm_enabled=true --ask-vault-pass

Per-role tests

See 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-pass

Run 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
done

Key variables

All 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

VM sizing (>=8 GB)

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.


OS hardening (external) — CRITICAL integration constraints

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:

  1. provision-vm.yml (or use an existing Debian 13 host).
  2. ansible-hardening-debian: bootstrap.yml then its site.yml.
  3. 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/subgid ranges exist for ncstack.
  • ptrace is not blocked for the run user (gVisor systrap needs it).
  • 80/443 open; 22 restricted to the management_cidrs CIDR allowlist (base-debian opens these via firewall_allow_tcp / management_cidrs).

An over-aggressive kernel.unprivileged_userns_clone=0 (or user.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 own inet f2b-table and the app declares its open ports via the firewall_* contract vars. A flush silently wipes every named table and every live ban. The tests/test-firewall_coexistence.yml gate proves base_filter survives a fail2ban ban cycle.


Isolation & rootless notes

  • App tier (rootless): nc-db, nc-redis, nc-app, nc-web, Collabora, euro-office, and the exporters run as the lingering ncstack user (UID 1500). Their Quadlets live in /home/ncstack/.config/containers/systemd/; manage them with systemctl --user as ncstack. podman-auto-update.timer runs in user scope.
  • Edge (rootful): the edge unit is a system Quadlet in /etc/containers/systemd/; manage it with systemctl as root. It binds 80/443 directly (rootful — no unprivileged-port sysctl needed) and runs under runsc.
  • gVisor runsc is installed at /usr/local/bin/runsc and registered as a Podman OCI runtime named runsc for both root podman (edge) and the rootless ncstack tier (euro_office Document Server). Platform systrap, never kvm. 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-web and Collabora are therefore published on 0.0.0.0 (:8443 / :9980) rather than loopback — but base-debian's base_filter INPUT chain drops those ports from the public interface (only the internal podman bridges, iifname "podman*", may reach them, via firewall_trust_iifnames), and the edge→nc-web hop 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 Recommends of netavark on Debian).
  • occ is run as podman exec -u www-data nc-app php occ <args> (as become_user: ncstack).

About

Ansible automation to deploy Nextcloud on Debian 13/Proxmox — rootless Podman + Quadlet app tier behind a rootful gVisor-sandboxed Caddy + Coraza WAF edge, mTLS between tiers

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages