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.
This commit is contained in:
tanshu committed 2026-10-16 08:50:53 +05:30
1 parent f431d94f06
commit 8136c2ddaf
3 files changed
+1466

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
# 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