From 060173db5ec11e16b84ab2215de72802833fdba9 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 15 Oct 2026 22:12:29 +0530 Subject: [PATCH] Run Caddy as a rootless Podman Quadlet Replaces the docker_container role with Quadlet systemd user units under ~/.config/containers/systemd, and moves state from /var/lib/caddy to ~/podman-data/caddy. Caddy is now the base unit and no longer lists its upstream networks. Each service registers itself by writing its own .network unit plus a drop-in into caddy.container.d/, so adding or removing a service never means editing the Caddy playbook. Drop-ins are used rather than blockinfile because this playbook re-templates caddy.container on every run, which would wipe appended blocks. The play runs as the connecting user instead of using become: the rootless units belong to it, so the connection's PAM session is what makes systemctl --user work without setting XDG_RUNTIME_DIR. podman_user and the data directory are derived from facts, so the play fails loudly if that resolves to root. Two things that bit us and are worth keeping: - The privileged-port sysctl is applied inline with sysctl -w, not via a handler. Handlers only run at the end of the play, by which point Caddy has already failed to bind 80/443. - The Caddyfile is seeded with a minimal valid config. Caddy exits 1 with a bare "EOF" on an empty config file, so touching it into existence made the service crash-loop. --- caddy/playbook.yml | 59 +-- caddy/readme.md | 347 ++++++++++++++++-- caddy/roles/caddy/handlers/main.yaml | 32 ++ caddy/roles/caddy/tasks/main.yaml | 115 ++++++ .../roles/caddy/templates/caddy.container.j2 | 40 ++ caddy/roles/podman/tasks/main.yaml | 80 ++++ caddy/roles/register/tasks/main.yaml | 66 ++++ caddy/vars/default.yml | 48 ++- 8 files changed, 708 insertions(+), 79 deletions(-) create mode 100644 caddy/roles/caddy/handlers/main.yaml create mode 100644 caddy/roles/caddy/tasks/main.yaml create mode 100644 caddy/roles/caddy/templates/caddy.container.j2 create mode 100644 caddy/roles/podman/tasks/main.yaml create mode 100644 caddy/roles/register/tasks/main.yaml diff --git a/caddy/playbook.yml b/caddy/playbook.yml index 03e298e..323d186 100755 --- a/caddy/playbook.yml +++ b/caddy/playbook.yml @@ -1,44 +1,23 @@ --- -- hosts: all - become: true +# Connects as the container user and runs everything as that user. The rootless +# Quadlet units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. Root-only steps +# (apt, sysctl, linger) elevate individually inside the podman role. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# Run with: ansible-playbook -u tanshu caddy/playbook.yml +- name: Deploy Caddy as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - vars/default.yml - tasks: - - name: Pull Caddy image - docker_image: - name: "{{ caddy_image }}" - source: pull - force_source: yes - - - name: Ensure Caddy Directories exist - file: - path: "/var/lib/{{ host_directory }}/{{ item }}" - state: directory - mode: 0755 - loop: - - conf - - site - - data - - config - - - name: Ensure Caddyfile exists - file: - path: "/var/lib/{{ host_directory }}/conf/Caddyfile" - state: touch - mode: 0644 - - - name: Create caddy container - docker_container: - name: "{{ caddy_name }}" - image: "{{ caddy_image }}" - state: started - restart_policy: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - "/var/lib/{{ host_directory }}/conf:/etc/caddy" - - "/var/lib/{{ host_directory }}/site:/srv" - - "/var/lib/{{ host_directory }}/data:/data" - - "/var/lib/{{ host_directory }}/config:/config" + roles: + # Rootless prerequisites: podman, lingering, low-port sysctl, Quadlet dir. + # Also guards against running as root. + - podman + # The base Caddy unit. It does NOT know which networks it serves. + # Each service registers itself via caddy/roles/register. + - caddy diff --git a/caddy/readme.md b/caddy/readme.md index f6d7ecc..ad6a35f 100644 --- a/caddy/readme.md +++ b/caddy/readme.md @@ -1,46 +1,321 @@ -# Docker on Ubuntu 18.04 +# Caddy (rootless Podman + Quadlet) -This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on -[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). -A number of containers will be created with the options specified in the `vars/default.yml` variable file. +Runs the Caddy reverse proxy as a **rootless Podman** container managed by +**systemd user units** (Quadlet). Replaces the previous `docker_container` role. + +**Caddy is the base unit and knows nothing about its upstreams.** It does not +list any app network and does not need editing when you add a service. Each +service registers *itself* with Caddy. That is the whole design. + +## Layout + +``` +caddy/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── podman/ # rootless prerequisites: podman, linger, port floor, Quadlet dir + ├── caddy/ # the base caddy.container unit + handlers + └── register/ # includable task file: a service registers itself with Caddy +``` + +Everything is installed under `{{ quadlet_dir }}` +(`~/.config/containers/systemd/`). State lives under `{{ host_directory }}` +(`~/podman-data/caddy/`). + +## How services register themselves + +Caddy's unit is minimal. A service joins it by writing **two files** — its own +network unit, and a Quadlet *drop-in* next to `caddy.container`: + +``` +~/.config/containers/systemd/ +├── caddy.container # base unit, no app networks +├── caddy.container.d/ # ← drop-ins, one per service +│ ├── 50-gitea.conf # [Container] Network=gitea.network +│ ├── 50-nextcloud.conf # [Container] Network=nextcloud.network +│ └── 50-mozimo.conf +├── gitea.network +├── nextcloud.network +└── mozimo.network +``` + +Each service playbook does: + +```yaml +- name: Register this service with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + vars: + network_name: "{{ container_name }}" +``` + +The play must also run the `podman` role (for `podman_user`, `quadlet_dir` and +the host prerequisites) and define a `Restart Caddy` handler — see +[roles/register/tasks/main.yaml](roles/register/tasks/main.yaml) for the exact +snippet. + +### Why drop-ins rather than editing caddy.container + +Editing the base file directly (e.g. with `blockinfile`) **breaks on the next run +of the Caddy playbook**, because that playbook re-templates `caddy.container` and +would wipe every service's appended block. Drop-ins sit beside the base file, +survive re-templating, and are the mechanism Quadlet documents for exactly this. + +They also make removal trivial — delete `50-mozimo.conf`, `daemon-reload`, and +Caddy drops that network. Nothing else changes. + +### Trade-off worth knowing + +A new drop-in changes the generated unit, so Caddy must be **recreated** to pick +up the network. That is a brief restart of *every* hosted site, not just the new +one. Quadlet has no live equivalent of `docker network connect`. + +If that matters, connect live first and persist the drop-in in the same task: +`podman network connect caddy` takes effect immediately, and the drop-in +makes it survive the next recreate. The `register` role does not do this by +default because it makes the play non-idempotent. + +### `isolate` + +The generated network units set `Options=isolate=true`, which blocks traffic +between separate Podman networks — an app on `nextcloud_net` cannot reach an app +that is only on `gitea_net`. Caddy and shared services still reach both, because +they are *members* of those networks. + +This is set explicitly on purpose: Podman 6.0 changed the default to +`isolate=strict`, and older Podman defaulted differently. Being explicit means the +isolation you get does not depend on which Podman version is installed. On Podman +6 you may prefer `strict`, which is stronger. + +## Multi-network mechanics (the parts that bite) + +**A container joins several networks by repeating `Network=`.** Quadlet turns each +`Network=.network` into a dependency on the generated network unit: + +```ini +[Container] +Network=nextcloud.network +Network=gitea.network +``` + +**You cannot use the `:alias=` form with a `.network` reference.** Quadlet only +applies its `.network` mapping when the value *ends with* `.network`. So +`Network=nextcloud.network:alias=db` is passed to Podman verbatim and fails, +because no network is actually named `nextcloud.network`. Use the separate key: + +```ini +NetworkAlias=db # applies to every network the container joins +``` + +**Naming.** `nextcloud.network` would otherwise create a Podman network called +`systemd-nextcloud`. `NetworkName=` in the `[Network]` section overrides that, so +we keep the Docker-era `nextcloud_net` names. Note the indirection: the *file* is +`.network`, the *Podman network* is `_net`. ## Settings -- `create_containers`: number of containers to create. -- `default_container_name`: default name for new containers. -- `default_container_image`: default image for new containers. -- `default_container_command`: default command to run on new containers. +| Variable | Purpose | +|---|---| +| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`) — not set by hand | +| `podman_data_dir` | Where state lives (`~/podman-data`), from `ansible_facts['user_dir']` | +| `quadlet_dir` | Where `.container` / `.network` units are written | +| `caddy_dropin_dir` | Where services register their networks | +| `podman_unprivileged_port_start` | Lowers the privileged-port floor so rootless Caddy can publish 80/443 | +| `caddy_image` | Fully qualified on purpose | +| `caddy_ports` | Host port mappings, including 443/udp for HTTP/3 | +| `caddy_name` | Container / unit name, and the prefix of the drop-in directory | +## Running -## Running this Playbook - -Quick Steps: - -### 1. Obtain the playbook -```shell -git clone https://github.com/do-community/ansible-playbooks.git -cd ansible-playbooks/docker_ubuntu1804 -``` - -### 2. Customize Options - -```shell -nano vars/default.yml -``` - -```yml -#vars/default.yml ---- -create_containers: 4 -default_container_name: docker -default_container_image: ubuntu -default_container_command: sleep 1d -``` - -### 3. Run the Playbook +The play targets `emily` directly (`hosts: emily`), so: ```command -ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml +ansible-playbook -u tanshu caddy/playbook.yml ``` -For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). \ No newline at end of file +The `podman` role installs the `podman` package, enables lingering, lowers the +privileged-port floor to 80, and creates the Quadlet directory. Target is Ubuntu +26.04 or later (Podman 5.7). + +Only `podman` is named explicitly. `aardvark-dns`, `nftables`, `passt` and +`uidmap` arrive as **Recommends** of podman/netavark, which a normal +`apt install` installs. That is fine by default, but it does mean they would go +missing under `--no-install-recommends`. `aardvark-dns` is the one that matters +most: it is the container-name DNS every `reverse_proxy :` depends +on, and nothing else in the dependency graph guarantees it. + +If you ever build Podman from source, this role would also pull in the distro +package — pin or skip it deliberately at that point. + +Order does not matter: services can be deployed before or after Caddy. A drop-in +referencing a `.network` that does not exist yet will fail unit generation, so +deploy the network unit and the drop-in together — which the `register` role does. + +## The Caddyfile + +`caddyfile_path` (`~/podman-data/caddy/conf/Caddyfile`) is **not** templated here. +It is seeded once and appended to by each service's `caddy` role via +`blockinfile`, exactly as before — only the path changed from +`/var/lib/caddy/conf/Caddyfile`. + +The seed matters: **Caddy exits 1 with a bare `EOF` on an empty config file**, so +an empty Caddyfile makes the service crash-loop. The role therefore writes a +minimal valid config when the file is missing or zero-length: + +``` +# Managed by Ansible (caddy/roles/caddy). +# Global options go in the block below. Service playbooks append their +# site blocks after it. +{ +} +``` + +It only acts when the file is empty, so it never fights the appended site blocks. +The global options block has to come *first* in a Caddyfile, which is why it is +seeded up front rather than appended. + +Service roles must also update their reload handler, which currently runs +`docker exec -w /etc/caddy caddy caddy reload`: + +```yaml +- name: Reload Caddy configuration + ansible.builtin.command: >- + podman exec caddy caddy reload --config /etc/caddy/Caddyfile + listen: "Reload Caddy" +``` + +A reference handler is in [roles/caddy/handlers/main.yaml](roles/caddy/handlers/main.yaml). +Note the container path is `/etc/caddy/Caddyfile` (the mount), not `caddyfile_path`. + +## Who runs as whom + +`podman_user` is **derived from the connection**, not configured: + +```yaml +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" +``` + +The play carries no `become`, so the modules run as — and the Quadlet units belong +to — whoever Ansible connects as. Running as that user is what gives us a real PAM +session, which is what makes `systemctl --user` work, with `XDG_RUNTIME_DIR` set by +`pam_systemd`. No `become_user`, no environment block. + +``` +ansible-playbook -u tanshu caddy/playbook.yml +``` + +The home directory comes from `ansible_facts['user_dir']`, so this is correct even for a +user whose home is not `/home/`. This is why `gather_facts: true` is set +explicitly on the play — the two facts above are the only configuration. + +Because the user is now implied rather than declared, the `podman` role **fails +loudly if it resolves to `root`**. That is the one case where deriving it would +silently produce something wrong: rootless units written into `/root`, and a +`systemctl --user` operating on root's user manager. The usual cause is +re-enabling `become` at play level, so the message says so. + +Using a *different* user is deliberately not supported — it would require +`become_user` for the user-facing tasks, which is the thing this design removes. +Only genuinely root-only steps elevate, each with its own `become: true`: + +| Task | Why root | +|---|---| +| `apt` install of podman + runtime pieces | package management | +| `loginctl enable-linger` | modifies system-wide linger state | +| `/etc/sysctl.d/99-podman.conf` + `sysctl -w …` | kernel setting | + +Everything else — directories, Quadlet files, `systemctl --user`, `podman exec` — +runs unprivileged as the connection user. + +## Operational notes + +Caddy is a **systemd user unit**: + +``` +systemctl --user status caddy +systemctl --user restart caddy +journalctl --user -u caddy -f +podman logs caddy +podman exec caddy caddy reload --config /etc/caddy/Caddyfile +podman inspect caddy --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' +``` + +From a plain root shell, user units need the runtime dir — `sudo -u` does not +give you a PAM session, which is exactly why the playbook avoids `become`: + +``` +sudo -u tanshu XDG_RUNTIME_DIR=/run/user/$(id -u tanshu) systemctl --user status caddy +``` + +Two prerequisites the `podman` role sets up, both easy to forget on a fresh +instance: + +- **`loginctl enable-linger tanshu`** — otherwise user units stop when the last + session ends and never start at boot. +- **`net.ipv4.ip_unprivileged_port_start = 80`** — rootless containers cannot bind + host ports below 1024 without it. The role writes + `/etc/sysctl.d/99-podman.conf` **and** applies it with `sysctl -w` in the same + run. It deliberately does not use a handler: handlers only fire at the end of + the play, by which point Caddy has already tried and failed to bind 80/443. + +`systemctl --user enable caddy` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +### Debugging a unit that will not generate + +``` +/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun +systemd-analyze --user --generators=true verify caddy.service +``` + +The most common cause is a `Network=` naming a `.network` file that does not exist. + +## Client IPs (verify before relying on them) + +Whether the original client address survives the host-to-container port forward +depends on the forwarding mechanism: + +- `rootlessport` — a userspace proxy that rewrites the source address. +- `pasta` — kernel-level forwarding that preserves it. + +The upstream switch, `rootless_port_forwarder = "pasta"` in `containers.conf`, is +Podman 6 only, so it is unavailable on Ubuntu 26.04's 5.7. + +**Not yet verified on emily.** Podman 5.7 uses pasta as the rootless network +backend, and pasta is what binds the published ports — visible in the journal as +`pasta[...]: Listen failed for HOST UDP port */443` when the port floor was still +1024. That shows pasta is in the path, but does not by itself prove the address is +preserved end-to-end. + +Check it once a site is serving — the Caddy access log should show a real client +address, not a gateway or `10.0.2.x`: + +``` +podman logs caddy | tail +``` + +If it is wrong and it matters (Nextcloud and Vaultwarden use client IPs for +brute-force protection; Vince tracks them for analytics), there is a second route +that works on stock Podman: **systemd socket activation**. systemd creates the +listening socket and passes it in, which preserves the source address natively and +at native speed. Caddy supports socket activation and `sd_notify`. It needs a +`caddy.socket` unit, `Notify=true` on the container, and a `bind fd/3` directive in +the Caddyfile — which is why it is not enabled here. + +## Migrating from the Docker version + +1. Deploy the services first, or at least their `.network` units. +2. Move state: `/var/lib/caddy` → `~/podman-data/caddy`, then + `chown -R tanshu:tanshu ~/podman-data/caddy`. + **Do not skip this** — `data/` holds the ACME account key and every issued + certificate. Losing it means re-issuing ~20 certificates against Let's Encrypt + rate limits. +3. Run this playbook. +4. Re-register each service (their `register` includes), then verify: + `podman exec caddy getent hosts nextcloud` resolves, and + `curl -sSI https://` works for each site block. + +Image drift to note: the running Docker container was `caddy:latest` while the old +vars said `caddy:alpine`. This role uses `caddy:latest` to match reality. diff --git a/caddy/roles/caddy/handlers/main.yaml b/caddy/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..f366401 --- /dev/null +++ b/caddy/roles/caddy/handlers/main.yaml @@ -0,0 +1,32 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_unit }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Caddy + +# Reference implementation for service playbooks. A service that appends a site +# block to {{ caddyfile_path }} should notify this instead of restarting the +# container, and defines its own copy of the handler in its caddy role, e.g.: +# +# - name: Reload Caddy configuration +# ansible.builtin.command: podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile +# listen: "Reload Caddy" +# +# Note the container path is /etc/caddy/Caddyfile (the mount), not caddyfile_path. +- name: Reload Caddy configuration + ansible.builtin.command: >- + podman exec {{ caddy_name }} + caddy reload --config /etc/caddy/Caddyfile + changed_when: true + listen: Reload Caddy diff --git a/caddy/roles/caddy/tasks/main.yaml b/caddy/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..4c3c35a --- /dev/null +++ b/caddy/roles/caddy/tasks/main.yaml @@ -0,0 +1,115 @@ +--- +# Start Caddy +# +# Everything here runs as {{ podman_user }} — the connection user. No become, +# no XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager +# is reachable via the standard environment. + +- name: Ensure Caddy directories exist + ansible.builtin.file: + path: "{{ host_directory }}/{{ item }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + loop: + - conf + - site + - data + - config + +- name: Check whether the Caddyfile already has content + ansible.builtin.stat: + path: "{{ caddyfile_path }}" + register: caddyfile_stat + +# Caddy exits 1 with a bare "EOF" on an empty config file, so an empty +# Caddyfile means caddy.service crash-loops. Seed a minimal valid config. +# +# Only acts when the file is missing or still empty, so it never fights the +# site blocks that service playbooks append via blockinfile. The global +# options block must come first, which is why it is seeded rather than +# appended. +- name: Seed the Caddyfile with a valid empty config + ansible.builtin.copy: + dest: "{{ caddyfile_path }}" + content: | + # Managed by Ansible (caddy/roles/caddy). + # Global options go in the block below. Service playbooks append their + # site blocks after it. + { + } + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + when: not caddyfile_stat.stat.exists or caddyfile_stat.stat.size == 0 + +- name: Ensure the Quadlet drop-in directory exists + ansible.builtin.file: + path: "{{ caddy_dropin_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +- name: Install the base Caddy Quadlet unit + ansible.builtin.template: + src: caddy.container.j2 + dest: "{{ quadlet_dir }}/{{ caddy_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Caddy + +- name: Start Caddy + block: + - name: Start Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_unit }}" + scope: user + state: started + daemon_reload: true + rescue: + # systemd reports "control process exited with error code" and nothing + # else, which is useless for the usual failures (a port already bound, a + # missing image, a bad volume). Pull the real reason out. + - name: Collect Caddy diagnostics + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ caddy_unit }} --no-pager" + - "journalctl --user -u {{ caddy_unit }} --no-pager -n 40" + register: caddy_diag + changed_when: false + failed_when: false + + - name: Show Caddy diagnostics + ansible.builtin.debug: + msg: "{{ caddy_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Caddy did not start — see the diagnostics above." + +# Quadlet honours [Install] WantedBy= when it generates the unit, so +# `systemctl enable` is neither needed nor possible on a generated unit. +- name: Verify Caddy is running + ansible.builtin.command: "systemctl --user is-active {{ caddy_unit }}" + register: caddy_active + changed_when: false + failed_when: caddy_active.stdout != 'active' + +- name: Show the networks Caddy is currently attached to + ansible.builtin.command: >- + podman inspect {{ caddy_name }} + --format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}' + register: caddy_networks_now + changed_when: false + failed_when: false + +- name: Report registered networks + ansible.builtin.debug: + msg: >- + Caddy networks: + {{ caddy_networks_now.stdout | default('(container not running)') }} diff --git a/caddy/roles/caddy/templates/caddy.container.j2 b/caddy/roles/caddy/templates/caddy.container.j2 new file mode 100644 index 0000000..0917f98 --- /dev/null +++ b/caddy/roles/caddy/templates/caddy.container.j2 @@ -0,0 +1,40 @@ +# Caddy reverse proxy — rootless Podman Quadlet unit. +# Managed by Ansible (caddy/roles/caddy). Do not edit by hand. +# +# Caddy deliberately does NOT list its upstream networks here. Each service +# registers its own network by dropping a file into +# {{ caddy_dropin_dir }}/, which Quadlet merges into this unit. +# See readme.md, "How services register themselves". +# +# The Caddyfile is likewise not templated here: each service appends its own +# site block to {{ caddyfile_path }} and then reloads Caddy. + +[Unit] +Description=Caddy reverse proxy +Documentation=https://caddyserver.com/docs/ +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ caddy_name }} +Image={{ caddy_image }} + +# Map the container's root (uid 0) to {{ podman_user }} on the host, so +# certificates and the Caddyfile stay owned by {{ podman_user }} and never by +# a subuid. Caddy can still bind 80/443 inside its own network namespace. +UserNS=keep-id:uid=0,gid=0 + +{% for port in caddy_ports %} +PublishPort={{ port }} +{% endfor %} +Volume={{ host_directory }}/conf:/etc/caddy +Volume={{ host_directory }}/site:/srv +Volume={{ host_directory }}/data:/data +Volume={{ host_directory }}/config:/config + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/caddy/roles/podman/tasks/main.yaml b/caddy/roles/podman/tasks/main.yaml new file mode 100644 index 0000000..631f74b --- /dev/null +++ b/caddy/roles/podman/tasks/main.yaml @@ -0,0 +1,80 @@ +--- +# Rootless Podman prerequisites. Shared by every container playbook. +# +# This playbook runs AS the container user; it does not use become to reach it. +# Root-only steps elevate individually. Keeping the connection's PAM session +# intact is what lets `systemctl --user` work without setting XDG_RUNTIME_DIR. + +- name: Fail if the connecting user resolved to root + ansible.builtin.assert: + that: podman_user != 'root' + fail_msg: >- + podman_user resolved to root, but this playbook configures *rootless* + Podman and writes Quadlet units into the user's home. Connect as the + unprivileged user that should own the containers, e.g. + `ansible-playbook -u tanshu ...`. If this appeared because become was + re-enabled at play level, drop it: the root-only tasks below elevate + individually. + +- name: Install Podman and the rootless runtime pieces + ansible.builtin.apt: + name: + - podman + # Everything below is only a Recommends, not a Depends, of podman or + # netavark. apt installs Recommends by default, so these would normally + # arrive on their own; listing them makes the requirement explicit and + # keeps it true under --no-install-recommends. + # - aardvark-dns # container-name DNS: Caddy resolves every upstream by name + # - nftables # netavark's firewall backend; Podman 6 dropped iptables + # - passt # pasta, the rootless network backend + # - uidmap # newuidmap/newgidmap, required for rootless user namespaces + state: present + update_cache: true + cache_valid_time: 3600 + become: true + +# Without lingering, the user's systemd manager is torn down when the last +# session ends: Quadlet units stop, and they never start at boot. +- name: Enable systemd lingering for {{ podman_user }} + ansible.builtin.command: "loginctl enable-linger {{ podman_user }}" + args: + creates: "/var/lib/systemd/linger/{{ podman_user }}" + become: true + +# Rootless containers cannot bind host ports below 1024 unless the kernel +# floor is lowered. Caddy publishes 80/443. +# +# This is applied inline rather than via a handler on purpose: handlers only +# run at the end of the play, by which point Caddy has already tried (and +# failed) to bind port 80. sysctl -w touches the running kernel; the file +# makes it survive a reboot. +- name: Read the current unprivileged port floor + ansible.builtin.command: sysctl -n net.ipv4.ip_unprivileged_port_start + register: podman_port_floor + changed_when: false + +- name: Persist the unprivileged port floor + ansible.builtin.copy: + dest: /etc/sysctl.d/99-podman.conf + owner: root + group: root + mode: "0644" + content: | + # Managed by Ansible (podman role). + net.ipv4.ip_unprivileged_port_start = {{ podman_unprivileged_port_start }} + become: true + +- name: Apply the unprivileged port floor + ansible.builtin.command: >- + sysctl -w net.ipv4.ip_unprivileged_port_start={{ podman_unprivileged_port_start }} + become: true + when: podman_port_floor.stdout | int > podman_unprivileged_port_start + changed_when: true + +- name: Ensure the Quadlet unit directory exists + ansible.builtin.file: + path: "{{ quadlet_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: "0755" diff --git a/caddy/roles/register/tasks/main.yaml b/caddy/roles/register/tasks/main.yaml new file mode 100644 index 0000000..c3ff6bd --- /dev/null +++ b/caddy/roles/register/tasks/main.yaml @@ -0,0 +1,66 @@ +--- +# Register a service's logical network with Caddy. +# +# Caddy is the *base* unit and knows nothing about its upstreams. Each service +# adds itself by writing two files into the Quadlet directory: +# +# 1. its own .network unit, and +# 2. a drop-in next to caddy.container that pulls that network into Caddy. +# +# Caddy's own playbook never changes when a service is added or removed. +# +# Include this from a service playbook: +# +# - name: Register this service with Caddy +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" +# vars: +# network_name: "{{ container_name }}" +# +# The including play must also run the `podman` role (for podman_user and +# quadlet_dir) and define a "Restart Caddy" handler: +# +# - name: Restart Caddy +# ansible.builtin.systemd_service: +# name: caddy.service +# scope: user +# state: restarted +# daemon_reload: true +# listen: Restart Caddy +# +# It runs as the connection user, which must be {{ podman_user }}. + +- name: "Create the {{ network_name }} network unit" + ansible.builtin.copy: + dest: "{{ quadlet_dir }}/{{ network_name }}.network" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + content: | + # {{ network_name }} — logical app network. + # Managed by Ansible. Do not edit by hand. + # + # NetworkName pins the Podman network name. Without it Quadlet would + # create "systemd-{{ network_name }}". + # + # isolate blocks traffic between separate Podman networks, so a container + # on this network cannot reach one that is only on another. Caddy and any + # shared service still reach it, because they are members of it. + [Network] + NetworkName={{ network_name }}_net + Options=isolate=true + notify: Restart Caddy + +- name: "Register {{ network_name }} with Caddy" + ansible.builtin.copy: + dest: "{{ quadlet_dir }}/{{ caddy_name | default('caddy') }}.container.d/50-{{ network_name }}.conf" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + content: | + # Added by the {{ network_name }} playbook. Do not edit by hand. + # + # Quadlet merges this into caddy.container. Deleting this file removes + # the network from Caddy on the next daemon-reload. + [Container] + Network={{ network_name }}.network + notify: Restart Caddy diff --git a/caddy/vars/default.yml b/caddy/vars/default.yml index 7fab30f..bb0406a 100644 --- a/caddy/vars/default.yml +++ b/caddy/vars/default.yml @@ -1,5 +1,47 @@ --- -caddy_name: caddy -caddy_image: caddy:alpine +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# The container user is derived from the connection: this play carries no +# become, so the modules run as — and the Quadlet units belong to — whoever +# Ansible connects as. Requires gather_facts (set explicitly in playbook.yml). +# +# To use a different user you would have to reintroduce become_user, which is +# exactly what this design avoids; the podman role fails loudly if the resolved +# user is root. +podman_user: "{{ ansible_facts['user_id'] }}" -host_directory: caddy +# State and config live in the user's home, not /var/lib. Taken from the +# user_dir fact, so this is correct even for users whose home is not +# /home/. +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" + +# Rootless containers cannot publish ports below 1024 without this sysctl. +# Caddy needs 80/443, so patching it down to 80 is the standard trade. +podman_unprivileged_port_start: 80 + +# --------------------------------------------------------------------------- +# Caddy +# --------------------------------------------------------------------------- +caddy_name: caddy +caddy_unit: "{{ caddy_name }}.service" + +# Fully qualified on purpose: rootless Podman resolves short names against +# unqualified-search-registries, and there is no TTY to answer a prompt when +# the systemd unit starts. +caddy_image: docker.io/library/caddy:latest + +host_directory: "{{ podman_data_dir }}/caddy" +caddyfile_path: "{{ host_directory }}/conf/Caddyfile" + +# Quadlet drop-in directory for the Caddy unit. Services register themselves +# here; see the "How services register themselves" section of readme.md. +# Caddy itself never lists its upstream networks. +caddy_dropin_dir: "{{ quadlet_dir }}/{{ caddy_name }}.container.d" + +# Host ports. 443/udp is HTTP/3 (QUIC). +caddy_ports: + - "80:80" + - "443:443" + - "443:443/udp"