Files
playbooks/docs/podman-compat-audit.md
T
tanshu 8136c2ddaf Add the Podman migration planning docs
Three documents written before the container migration, all referenced from the
service readmes, so they belong in the tree:

- podman-compat-audit.md -- a read-only audit of the monoco fleet: every image's
  architecture, privileges and namespaces; the four things that actually break
  (anonymous volumes, per-container uids, tooling that assumes Docker, image
  naming); and the per-container uid matrix that the UserNS=keep-id lines in the
  Quadlet units come from.
- podman-migration.md -- the plan: target architecture, the ownership/chown
  pass, the Quadlet unit shape, and the low-port and lingering prerequisites.
- backup-plan.md -- per-service irreplaceable state, the consistency problems
  (live SQLite WAL, hot Postgres, the stale bitwarden SQLite), and the 3-2-1
  architecture.

caddy, postgres, tandoor and bitwarden each link into these from their readme,
so leaving them untracked left those links dead.
2026-10-16 08:50:53 +05:30

319 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Podman compatibility audit — monoco container fleet
Status: **planning only.** Read-only inspection; nothing on monoco was modified.
Scope: all 26 running containers, audited for rootless Podman + Quadlet.
Companion docs: [podman-migration.md](podman-migration.md), [backup-plan.md](backup-plan.md)
---
## 1. Verdict
**Nothing in this fleet blocks a rootless Podman migration.** There is no privileged
container, no capability add, no device, no host networking, no custom seccomp, no
Docker-socket mount, and no Compose file. Every image is **arm64**, so there is no
emulation.
The breakages are all in the *surrounding tooling* (backup scripts, cron, Ansible
modules) and in *ownership/volume handling* — not in the images. Your instinct that
your own `registry.tanshu.com` images will be fine is correct; I checked them
specifically and they are among the cleanest in the fleet.
Four things need real attention, and one of them is not obvious:
| # | Issue | Severity |
|---|---|---|
| 1 | `hiwikijs` and `valkey` use **anonymous Docker volumes** — copying `/var/lib/*` misses them entirely | **data loss** |
| 2 | Every data tree needs re-owning, and **each app's uid differs** (0 / 33 / 70 / 999 / 1000 / 1003) | blocks startup |
| 3 | `--link` in backup scripts, and root's crontab doing `docker exec` | breaks backups + cron |
| 4 | Locally-built `sms-wa-bridge` has no registry — short-name resolution fails | won't start |
---
## 2. What I verified as clean
Worth stating explicitly, because it is why this migration is tractable:
- **Architecture**: every image is `arm64` / `linux`. No binfmt/qemu needed.
- **Privileges**: `Privileged=false`, `CapAdd=[]`, `CapDrop=[]`, `Devices=[]` on all 26.
- **Namespaces**: no `--network host`, no `--pid host`, no `--ipc host`, no `--userns host`.
- **Security**: no custom `SecurityOpt`, no custom seccomp profile, no `--extra-hosts`.
- **Docker socket**: not mounted anywhere. No image talks to the Docker API.
- **Compose**: none. Every container is a plain `docker run` / Ansible `docker_container`.
- **shm**: all at the 64 MB default; nothing needs a larger `/dev/shm`.
- **Sysctls**: none set.
- **Resource limits**: `Memory=0`, `NanoCpus=0` on every container. (The Ansible role
for gitea *declares* `cpus: .10` / `memory: 512M`, but they are **not in effect** on
the running container — so no cgroup-delegation concerns for rootless.)
- **Custom DNS**: `Dns=[]` everywhere; aardvark-dns will replace Docker's `127.0.0.11`.
- **Hostnames**: all default (container ID). Nothing depends on a fixed hostname —
`sms-wa-bridge` explicitly cleans its own `Singleton*` locks on start because of this.
---
## 3. The four real problems
### 3.1 Anonymous volumes — the one that loses data
Eight images declare `VOLUME`:
| Image | VOLUME | Mounted? |
|---|---|---|
| `vaultwarden/server` | `/data` | ✅ bind-mounted |
| `gitea/gitea` | `/data` | ✅ bind-mounted |
| `registry.tanshu.com/{greatbear,hops}` | `/app/pb_data` | ✅ bind-mounted |
| `nextcloud` | `/var/www/html` | ✅ bind-mounted |
| `postgres:17-alpine` | `/var/lib/postgresql/data` | ✅ bind-mounted |
| **`ghcr.io/requarks/wiki`** | `/wiki/data/content` | ❌ **anonymous volume** |
| **`valkey/valkey:alpine`** | `/data` | ❌ **anonymous volume** |
The two anonymous ones live in `/var/lib/docker/volumes/<hash>/_data`, **not** under
`/var/lib/<service>`. A copy of the data directories will silently omit them.
`hiwikijs` is the serious one: `/var/lib/hiwikijs` exists but is an **empty directory**
from Dec 2022, and `~/bin/hiwikijs-backup.sh` archives exactly that empty directory and
exits successfully. So Wiki.js currently has **no backup at all** — this predates the
migration and should be fixed regardless of it.
Fix: export both to real directories (e.g. `~/podman-data/hiwikijs/content`,
`~/podman-data/valkey`) and declare them as Quadlet `Volume=` bind mounts. `valkey` is a
rebuildable cache; `hiwikijs` is not.
### 3.2 Ownership — one uid per container, and they all differ
Under rootless Podman the user namespace maps only **one** uid:gid to your host user.
The rule is `UserNS=keep-id:uid=<app-uid>,gid=<app-gid>`, where `<app-uid>` is the uid
the application actually writes as — **not** the image's `USER` and **not** `docker exec id`.
Measured (see §4 for the full matrix):
| App uid | Containers |
|---|---|
| 0 | caddy, brewman ×5, greatbear, hops, affine, audiobookshelf, bitwarden, tandoor, sms-wa-bridge, vince |
| 33 | nextcloud |
| 70 | postgres |
| 999 | valkey |
| 1000 | mozimo, mozimo-staging, hiwikijs, menu-app ×3, yamtrack, trillium |
| 1003 | gitea |
Trap: `docker exec <c> id -u` reports the **image's** configured user, not the running
process. It says `0` for `postgres` (which actually writes as **70**), for `nextcloud`
(**33**), and for `gitea` (**1003**) — because those entrypoints drop privileges after
starting. Derive the uid from **the ownership of what the app writes**, which is what
the table above does.
Also note `tanshu` is **uid 1001**, not 1000 — most of the uid-1000 data is currently
owned by the `ubuntu` user. "Chown it to me" is therefore not a no-op anywhere.
The reassuring part: for the entrypoints that start as root and drop to an app user
(`postgres`, `nextcloud`, `gitea`, `trillium`, `yamtrack`), the root phase maps to a
subuid and its internal `chown` still lands on your host user. So a single
`keep-id:uid=<app-uid>` plus `chown -R tanshu:tanshu` should work — **but verify per
service**, because a file written during the root phase and never chowned will be
subuid-owned and unreadable by the dropped-privilege process.
### 3.3 Tooling that assumes Docker
- **`--link postgres:db`** in `db-backup.sh`, `bitwarden-`, `hiwikijs-`, `kakawa-`,
`nextcloud-`, `tandoor-backup.sh` and their restores. No Podman equivalent.
- **Root's crontab**: `*/5 * * * * docker exec -u www-data nextcloud php cron.php`.
Root **cannot** `podman exec` into another user's rootless containers — this must
become a systemd **user** timer.
- **`~/bin/empty-trash.sh`** execs a container named `cloud`, which no longer exists
(it is `nextcloud`).
- **Ansible**: `docker_container` / `docker_image` / `docker_network` →
`containers.podman`. Note `podman_network` has **no** `connected:`/`appends:`
parameters, which every `*/roles/network/tasks/main.yaml` in this repo relies on.
Declare the full `network:` list on each `podman_container` instead.
- **Everything outside this repo.** You said ~90% is Ansible and the rest is
`docker run`. The deploy pipelines for `mozimo`, `brewman`, `menu-admin`, `hops`,
`greatbear` and `sms-wa-bridge` live elsewhere — if they still call `docker` against
the host, they will recreate Docker containers alongside your Podman ones.
### 3.4 Image naming
Rootless Podman resolves short names against `unqualified-search-registries`. In a
**Quadlet/systemd context there is no TTY to confirm a short-name prompt**, so
ambiguous names fail the unit rather than prompting.
- **`sms-wa-bridge`** was built locally with `docker build -t sms-wa-bridge` — no
registry. Podman will try `docker.io/library/sms-wa-bridge` and fail. Transfer it with
`docker save sms-wa-bridge | podman load` and reference **`localhost/sms-wa-bridge:latest`**.
- `nextcloud`, `caddy`, `postgres`, `valkey/valkey`, `gitea/gitea`, `vaultwarden/server`,
`vabene1111/recipes`, `triliumnext/notes` are all short or implicit-`docker.io` names.
**Fully qualify all of them** (`docker.io/library/nextcloud:latest` etc.) so resolution
never depends on host config.
- `registry.tanshu.com/*` and `ghcr.io/*` are already qualified. ✅
---
## 4. Per-container matrix
`App uid` is what the application writes as. `Anon vol` = uses an anonymous Docker volume.
| Container | Image | App uid | Anon vol | Verdict | Notes |
|---|---|---|---|---|---|
| `caddy` | `caddy:latest` | 0 | – | ⚠️ | Needs `ip_unprivileged_port_start≤80`; publishes 80, 443, **443/udp (HTTP/3)** — verify UDP forwarding |
| `postgres` | `postgres:17-alpine` | **70** | – | ⚠️ | PGDATA is `pgdata/` under the mount, owned uid 70. Drop the `0.0.0.0:5432` publish |
| `valkey` | `valkey/valkey:alpine` | **999** | **YES** | ⚠️ | Export the anonymous volume |
| `nextcloud` | `nextcloud:latest` | **33** | – | ✅ | `VOLUME /var/www/html` is bind-mounted; needs `POSTGRES_HOST=db` / `REDIS_HOST=redis` aliases |
| `gitea` | `gitea/gitea:latest` | **1003** | – | ⚠️ | `/var/lib/gitea/ssh` holds **SSH host keys** — copy them or clients see host-key warnings |
| `mozimo` | `registry.tanshu.com/mozimo:4a3863a` | 1000 | – | ✅ | Image `USER=node`; entrypoint starts PocketBase + SSR together |
| `mozimo-staging` | `registry.tanshu.com/mozimo:3929d3f` | 1000 | – | ✅ | |
| `greatbear` | `registry.tanshu.com/tanshu/greatbear` | 0 | – | ✅ | `VOLUME /app/pb_data` bind-mounted |
| `hops` | `registry.tanshu.com/hops:latest` | 0 | – | ✅ | `VOLUME /app/pb_data` bind-mounted |
| `menu-app.greatbear.in` | `registry.tanshu.com/menu-admin` | 1000 | – | ⚠️ | **Container names contain dots** — name the Quadlet units `menu-greatbear.container` etc. and set `ContainerName=` explicitly so the Caddyfile keeps resolving |
| `menu-app.hngmohali.com` | ″ | 1000 | – | ⚠️ | ″ |
| `menu-app.hopsngrains.com` | ″ | 1000 | – | ⚠️ | ″ |
| `hiwikijs` | `ghcr.io/requarks/wiki:latest` | 1000 | **YES** | ❌ | Content is in an anonymous volume; `/var/lib/hiwikijs` is an empty decoy |
| `affine` | `ghcr.io/toeverything/affine-graphql:stable` | 0 | – | ✅ | Needs postgres + valkey; indexer disabled (`AFFINE_INDEXER_ENABLED=false`), so no pgvector dependency |
| `audiobookshelf` | `ghcr.io/advplyr/audiobookshelf` | 0 | – | ✅ | `tini` entrypoint; 15 GB is media, state is 22 MB |
| `bitwarden` | `vaultwarden/server:latest` | 0 | – | ⚠️ | Authoritative store is **Postgres**; the `db.sqlite3` on disk is stale — do not restore it |
| `yamtrack` | `ghcr.io/fuzzygrim/yamtrack` | 1000 | – | ✅ | SQLite + WAL; needs a consistent copy |
| `trillium` | `triliumnext/notes:latest` | 1000 | – | ✅ | `document.db` + WAL; has its own `backup/` dir |
| `tandoor` | `vabene1111/recipes:latest` | 0 | – | ✅ | Needs `POSTGRES_HOST=db` alias |
| `sms-wa-bridge` | `sms-wa-bridge` (local) | 0 | – | ⚠️ | Chromium **is fine** (see below). Must be `localhost/sms-wa-bridge:latest` |
| `vince` | `ghcr.io/vinceanalytics/vince` | 0 | – | ⚠️ | **Distroless** — no shell, no `id`, so no exec-based checks. Container was run with `serve`, image `CMD` is null → set `Exec=serve` |
| `brewman-acc/exp/hinchco/hops/mhl` | `registry.tanshu.com/brewman:latest` | 0 | – | ✅ | 5 containers, identical config, one DB each |
### The Chromium question, answered
`sms-wa-bridge` is the classic rootless-Podman landmine: Chromium normally needs
`CLONE_NEWUSER` for its sandbox, which is blocked inside a container, and it can also
die on a 64 MB `/dev/shm`. I checked `server.js` and it already handles both:
```js
puppeteer: {
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
}
```
`--no-sandbox` removes the userns dependency and `--disable-dev-shm-usage` removes the
shm dependency. **No action needed** — this container will behave identically.
---
## 5. Your migration thesis — validated, with three corrections
> *"if we copy the data directories, change ownership and deploy on new server, we should be good to go"*
**Broadly yes** — and with downtime free, this is a much safer plan than an in-place
cutover, because you can stop everything cleanly and copy quiesced data. Three
corrections:
**Correction 1 — "the data directories" is not all the state.** Adding to `/var/lib/*`:
- the two **anonymous volumes** (§3.1) — `hiwikijs` is genuinely at risk;
- **`/var/lib/gitea/ssh`** (SSH host keys) — easy to miss, and clients will complain;
- **`/var/lib/caddy/data`** — the ACME account key and ~20 certificates. Lose it and you
re-issue everything against Let's Encrypt rate limits;
- **the environment variables** — that is where the credentials live (Shopify client
secret, Sentry token, JWT private keys, PocketBase passwords, DB passwords);
- **`/etc/crontab` entries and `~/bin/`** — the operational glue.
**Correction 2 — "change ownership" is per-container, not global.** Chowning everything
to `tanshu` is right, but it only works if each container is also given the matching
`UserNS=keep-id:uid=<app-uid>`. Get the pairing wrong and the app starts and then fails
to write — the failure mode is a permissions error at runtime, not a clean startup
failure. The §4 table gives the pairing.
**Correction 3 — Postgres wants a clean stop, and a dump is still worth it.** Copying
`PGDATA` after a clean shutdown is valid *at the same major version* (17.5 here). But a
logical dump is cheap and gives you a version-independent fallback. Do both.
Since downtime is not a constraint, the copying step should be:
```
1. Stop all containers cleanly.
2. Copy: /var/lib/<svc>/, /var/lib/gitea/ssh, the two anonymous volumes,
/var/lib/caddy/data, ~/bin/, env vars, docker inspect JSON.
3. Verify checksums on the copy.
4. chown -R tanshu:tanshu on the destination, per §3.2.
5. Load images (docker save | podman load, or podman pull + podman login).
6. Write Quadlet units, set UserNS / Network / Volume.
7. systemctl --user daemon-reload && start the data tier, then apps, then Caddy.
```
Because you stop cleanly, the SQLite WAL problem from the backup plan largely
disappears — no live WAL, no torn copies. That is the single biggest simplification
this approach buys you.
---
## 6. Low ports and lingering
You already know both, confirming for completeness:
**Ports.** Rootless cannot bind < 1024. Caddy binds 80/443 **inside** its own network
namespace (where container-root has `CAP_NET_BIND_SERVICE`, which Podman grants by
default), but the **host-side publish** needs the sysctl:
```
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-podman.conf
```
Verify after reboot — this is a favourite thing to forget on a fresh instance.
**Lingering.** Without it, Quadlet **user** services stop when the login session ends,
which on a server is immediately, and they do not start at boot:
```
sudo loginctl enable-linger tanshu
```
Also confirm `/etc/subuid` and `/etc/subgid` have a range for the new user — monoco has
`tanshu:165536:65536`, and a fresh instance will assign a different base. Do not
hardcode `165536` anywhere.
---
## 7. Cutover notes specific to a new instance
**DNS and certificates are the thing to plan around.** All ~20 hostnames resolve to
`13.202.6.2`. On a new instance with a new IP:
- Caddy cannot obtain certificates until DNS points at the new host (HTTP-01), and the
existing certs in `/var/lib/caddy/data` are only valid to *serve* for those names once
traffic arrives.
- **Check whether `13.202.6.2` is an Elastic IP.** If it is, reassign it to the new
instance and DNS, certificates, and clients are all untouched — by far the cleanest
cutover. Worth confirming before designing around a DNS change.
**Logs do not migrate.** All 26 containers use the `json-file` driver; Podman uses
journald. If the existing logs matter, export them before decommissioning. Otherwise
`podman logs` / `journalctl --user -u <svc>` replaces `docker logs`.
**Image transfer.** `docker save <img> | podman load` avoids re-downloading (and avoids
registry auth for the local image). For `registry.tanshu.com`, run `podman login` on the
new host so `podman auto-update`/pulls work.
**The OS question is still open.** Per [podman-migration.md §3.4](podman-migration.md),
a **fresh 26.04 instance with stock Podman 5.7 needs no source builds at all**. Since you
are standing up a new instance anyway, this is now essentially free — and it makes the
"build Podman 6 for pesto" question entirely separable from the migration.
---
## 8. Per-service verification after cutover
For each container, in this order:
1. **Starts and stays up** — `systemctl --user status <svc>`
2. **Writes as the right uid** — create/update something through the app, then confirm
on the host that the new file is owned by `tanshu`. This is the check that catches a
wrong `keep-id`.
3. **Resolves its dependencies** — `podman exec <c> getent hosts postgres` (and `db` /
`redis` aliases where used)
4. **Healthcheck reports healthy** — `podman inspect --format '{{.State.Health.Status}}'`
5. Special cases:
- `caddy`: real client IPs in the access log (this is where the rootless `rootlessport`
limitation shows up — see §3.4 of the migration plan); `https://<vhost>` responds;
HTTP/3 (`curl --http3`) if you care
- `sms-wa-bridge`: WhatsApp re-links (or restores) and a test message sends
- `gitea`: `git clone` over **both** HTTPS and SSH (port 2222), no host-key warning
- `postgres`: every client app connects; nothing listening on host `5432`
- `nextcloud`: upload a file (exercises the uid-33 mapping and `REDIS_HOST`)
- `vince`: reachable on `stats.mozimo.in` (distroless, so no shell to diagnose with)
6. **Reboot the host** and confirm everything returns unattended