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.
17 KiB
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, 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/ Ansibledocker_container. - shm: all at the 64 MB default; nothing needs a larger
/dev/shm. - Sysctls: none set.
- Resource limits:
Memory=0,NanoCpus=0on every container. (The Ansible role for gitea declarescpus: .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's127.0.0.11. - Hostnames: all default (container ID). Nothing depends on a fixed hostname —
sms-wa-bridgeexplicitly cleans its ownSingleton*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:dbindb-backup.sh,bitwarden-,hiwikijs-,kakawa-,nextcloud-,tandoor-backup.shand their restores. No Podman equivalent.- Root's crontab:
*/5 * * * * docker exec -u www-data nextcloud php cron.php. Root cannotpodman execinto another user's rootless containers — this must become a systemd user timer. ~/bin/empty-trash.shexecs a container namedcloud, which no longer exists (it isnextcloud).- Ansible:
docker_container/docker_image/docker_network→containers.podman. Notepodman_networkhas noconnected:/appends:parameters, which every*/roles/network/tasks/main.yamlin this repo relies on. Declare the fullnetwork:list on eachpodman_containerinstead. - Everything outside this repo. You said ~90% is Ansible and the rest is
docker run. The deploy pipelines formozimo,brewman,menu-admin,hops,greatbearandsms-wa-bridgelive elsewhere — if they still calldockeragainst 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-bridgewas built locally withdocker build -t sms-wa-bridge— no registry. Podman will trydocker.io/library/sms-wa-bridgeand fail. Transfer it withdocker save sms-wa-bridge | podman loadand referencelocalhost/sms-wa-bridge:latest.nextcloud,caddy,postgres,valkey/valkey,gitea/gitea,vaultwarden/server,vabene1111/recipes,triliumnext/notesare all short or implicit-docker.ionames. Fully qualify all of them (docker.io/library/nextcloud:latestetc.) so resolution never depends on host config.registry.tanshu.com/*andghcr.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:
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) —
hiwikijsis 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/crontabentries 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/dataare only valid to serve for those names once traffic arrives. - Check whether
13.202.6.2is 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, 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:
- Starts and stays up —
systemctl --user status <svc> - 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 wrongkeep-id. - Resolves its dependencies —
podman exec <c> getent hosts postgres(anddb/redisaliases where used) - Healthcheck reports healthy —
podman inspect --format '{{.State.Health.Status}}' - Special cases:
caddy: real client IPs in the access log (this is where the rootlessrootlessportlimitation shows up — see §3.4 of the migration plan);https://<vhost>responds; HTTP/3 (curl --http3) if you caresms-wa-bridge: WhatsApp re-links (or restores) and a test message sendsgitea:git cloneover both HTTPS and SSH (port 2222), no host-key warningpostgres: every client app connects; nothing listening on host5432nextcloud: upload a file (exercises the uid-33 mapping andREDIS_HOST)vince: reachable onstats.mozimo.in(distroless, so no shell to diagnose with)
- Reboot the host and confirm everything returns unattended