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 <name>.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.
This commit is contained in:
1 parent
21f848340e
commit
060173db5e
8 files changed
+708
-79
No files matched your search
+19
-40
@@ -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
|
||||
+311
-36
@@ -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 <net> 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=<name>.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
|
||||
`<name>.network`, the *Podman network* is `<name>_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).
|
||||
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 <name>:<port>` 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/<name>`. 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://<vhost>` 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.
|
||||
@@ -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
|
||||
@@ -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)') }}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 <name>.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
|
||||
+45
-3
@@ -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/<name>.
|
||||
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"
|
||||
Reference in new issue
Block a user