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
|
# Connects as the container user and runs everything as that user. The rootless
|
||||||
become: true
|
# 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_files:
|
||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
tasks:
|
roles:
|
||||||
- name: Pull Caddy image
|
# Rootless prerequisites: podman, lingering, low-port sysctl, Quadlet dir.
|
||||||
docker_image:
|
# Also guards against running as root.
|
||||||
name: "{{ caddy_image }}"
|
- podman
|
||||||
source: pull
|
# The base Caddy unit. It does NOT know which networks it serves.
|
||||||
force_source: yes
|
# Each service registers itself via caddy/roles/register.
|
||||||
|
- caddy
|
||||||
- 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"
|
|
||||||
+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
|
Runs the Caddy reverse proxy as a **rootless Podman** container managed by
|
||||||
[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).
|
**systemd user units** (Quadlet). Replaces the previous `docker_container` role.
|
||||||
A number of containers will be created with the options specified in the `vars/default.yml` variable file.
|
|
||||||
|
**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
|
## Settings
|
||||||
|
|
||||||
- `create_containers`: number of containers to create.
|
| Variable | Purpose |
|
||||||
- `default_container_name`: default name for new containers.
|
|---|---|
|
||||||
- `default_container_image`: default image for new containers.
|
| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`) — not set by hand |
|
||||||
- `default_container_command`: default command to run on new containers.
|
| `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
|
The play targets `emily` directly (`hosts: emily`), so:
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```command
|
```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