sms-wa-bridge (rootless Podman + Quadlet)
Runs sms-wa-bridge on emily as a rootless Podman container managed by a
systemd user unit (Quadlet), behind the shared rootless Caddy.
Replaces the docker run container on monoco.
The service is SMS webhook → routing rules → WhatsApp, with no LLM and no
OpenClaw in the path: an Android automation (MacroDroid) POSTs every SMS to
https://bridge.tanshu.dev/sms, rules.json decides who should get it, and
whatsapp-web.js sends it from a WhatsApp account linked by QR code. The app
source lives in files/build/, and the public contract is
unchanged — same host, same path, same header, same body.
Two things are worth knowing before anything else:
- The image exists in no registry. It was built on monoco with
docker build -t sms-wa-bridge, so it has to be carried across withdocker save | podman loador rebuilt here. Why also explains why reusing the loaded image is the safer of the two. - The state that matters is a Chromium profile, not a database:
wwebjs_auth/holds the linked WhatsApp device, and it is the difference between a cutover and asking somebody to scan a QR code again. See Migrating the data from monoco.
Ordering
- caddy/playbook.yml — the reverse proxy, which owns the Caddyfile this site appends to and is the one play that applies the rootless-Podman prerequisites.
- The image and the data copy below — before the first run of this playbook.
The app writes a fresh QR session into
~/podman-data/sms-wa-bridgethe moment it starts, and a first start on an empty directory is a bridge that has to be linked from scratch. - This playbook — run it after the copy, not before.
What the bridge serves
| Endpoint | Auth | What it does |
|---|---|---|
POST /sms |
X-Auth: <AUTH_SECRET> |
Body {"from":"…","text":"…"}. Routes it and sends WhatsApp messages; 200 sent xN, 200 skipped (no rule matched), 401, 400, or 500 if a send failed |
GET /health |
none | {"waReady":bool,"rules":"loaded"|"missing"} |
GET /qr?auth=<AUTH_SECRET> |
query param | The current pairing QR code as a PNG, for headless linking |
Routing, from files/build/rules.json — the first matching route wins, and the file is re-read on every request, so edits apply without a restart:
| Route | Matches | Goes to |
|---|---|---|
hdfc-otp |
sender contains hdfc and text contains otp |
two numbers |
icici-otp |
sender contains icici and text contains otp |
two numbers |
| fallback | any other text containing otp |
one number |
| — | everything else | dropped, and logged |
Layout
sms-wa-bridge/
├── playbook.yml
├── readme.md
├── vars/default.yml
├── files/build/ # the image build context: Dockerfile, server.js,
│ # package.json, entrypoint.sh, rules.json
└── roles/
├── sms-wa-bridge/ # data tree, env file, image, Quadlet unit, verify
└── caddy/ # appends this site's block to the shared Caddyfile
| On the host | What |
|---|---|
~/.config/containers/systemd/sms-wa-bridge.container |
the generated unit (Ansible-templated) |
~/.config/containers/systemd/sms-wa-bridge.network |
the app network, written by caddy/roles/register |
~/.config/containers/systemd/caddy.container.d/50-sms-wa-bridge.conf |
attaches Caddy to that network |
~/podman-data/sms-wa-bridge/ |
everything the bridge owns — see below |
~/podman-data/sms-wa-bridge-build/ |
the copied build context, used only when the image is built here |
and inside that data directory, the same four entries monoco has under
/var/lib/sms-wa-bridge/:
| Entry | What |
|---|---|
wwebjs_auth/ |
the linked-device session: a Chromium profile — 336 MB, and 1,700 of the tree's 1,707 files |
wwebjs_session/ |
empty, and vestigial — unused by the current whatsapp-web.js, mounted so the tree keeps its shape |
rules.json |
routing rules, seeded once and then never overwritten |
bridge.log |
the app's own log, appended by the app |
How it works
There is no registry for this image
monoco's image is tagged sms-wa-bridge:latest and lives only in its Docker
daemon. Two ways across, and the playbook finds either:
# The recommended route: byte-identical, and it is the image the paired session
# was linked against.
set -o pipefail
ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load'
A .container file cannot rely on short-name resolution, because there is no TTY
to answer podman's registry prompt at unit start. The unit therefore asks for
localhost/sms-wa-bridge:latest and sets Pull=never, so a missing image fails
loudly instead of turning into a Docker Hub lookup.
podman load qualifies an unqualified docker tag against the default registry
rather than localhost — verified on emily with a real docker save round trip —
so the loaded image arrives as docker.io/library/sms-wa-bridge:latest. The play
re-tags it to the localhost/... name above, and the manual command in
Migrating the data does the same. Skipping the
re-tag is not fatal: the play rebuilds the image instead.
That rebuild is the other path — podman build from
files/build/Dockerfile, which the play does by itself
when no image is present under either name. It is slower (the Dockerfile installs
Chromium and its libraries) and it re-resolves the dependencies, which matters:
package.jsonpinswhatsapp-web.jsat^1.26.0. A rebuild can therefore install a newer client than the one running on monoco, and a client that has moved its session format will refuse the copiedwwebjs_auth/and ask for a QR scan. That is the whole argument for loading the image rather than rebuilding one. Rebuild deliberately, with-e rebuild_image=true, and never as a surprise during a cutover.
Who runs as whom
UserNS=keep-id:uid=0,gid=0: the container's root is tanshu on the host, and
nothing else is mapped. It is the same mapping audiobookshelf
and caddy use, and it is the right one here because the
image has no USER instruction — it runs as container-root throughout, and on
monoco everything under /var/lib/sms-wa-bridge/ is 0 0 (checked with
ls -ln), which is exactly the root the app wrote as there.
The consequence to internalise is on the migration side, not the deploy side:
host uid 0 is not mapped into the container at all. A rules.json or session
file copied with its original root ownership is unwritable from inside the
container — the app starts, then fails to write the session or append to the log,
which is the same class of failure postgres/readme.md
documents for PGDATA. So the copy uses tar --no-same-owner, and the play
asserts, before it starts anything, that nothing under the data directory belongs
to another user.
The mounts
The four mounts are the Docker-era container's, unchanged in intent:
~/podman-data/sms-wa-bridge/wwebjs_auth -> /app/.wwebjs_auth
~/podman-data/sms-wa-bridge/wwebjs_session -> /app/.wwebjs_session
~/podman-data/sms-wa-bridge/rules.json -> /app/rules.json
~/podman-data/sms-wa-bridge/bridge.log -> /app/bridge.log
Two of them are single files, which is the trap worth knowing about: podman
creates a missing bind-mount source as a directory. With a directory where
bridge.log should be, every append fails; with one where rules.json should
be, the per-request re-read throws. So roles/sms-wa-bridge
touches bridge.log and seeds rules.json before the unit starts — and seeds
rules.json with force: false, so the play never reverts a live edit.
There is no :Z or :z on the mounts because there is no SELinux on these hosts
(Ubuntu, AppArmor); the other playbooks in this repository do the same.
Chromium under rootless Podman
This is the classic rootless landmine, and server.js already defuses it:
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
--no-sandbox removes the CLONE_NEWUSER requirement that a nested user
namespace would block, and --disable-dev-shm-usage removes the 64 MB /dev/shm
dependency. Confirmed by inspection in
docs/podman-compat-audit.md, "The Chromium
question, answered". The unit needs no extra capability, no bigger /dev/shm,
and no --privileged for this.
The other Chromium wrinkle is already handled in the image's own
entrypoint.sh: it deletes
/app/.wwebjs_auth/session/Singleton* on every start. Those are Chromium profile
locks that name the host that created them, and a container recreate changes the
hostname — on monoco the three of them (SingletonSocket, SingletonCookie,
SingletonLock) are stale symlinks for exactly that reason. They travel with the
copy, and the entrypoint clears them.
What "healthy" means here
The image ships no HEALTHCHECK, so the unit defines one and the play gates on
it. /health answers 200 whenever the HTTP server is up — which is not the
same as being useful. An unlinked bridge is a perfectly healthy process that
accepts every SMS and answers none of them: server.js queues the send until the
WhatsApp client emits ready, and nothing in it times out, so the POST stays open
and no message is delivered. So the play reads the payload back and reports it:
waReady=false, rules=loaded
and so does a browser: https://bridge.tanshu.dev/health. waReady only turns
true once a linked WhatsApp device is connected — on a migration, from the copied
session, on its own and without a QR code being scanned anywhere.
Linking a device, if it comes to that
The QR code is printed into the log and is also served as a PNG, which is what makes linking possible over SSH:
podman logs -f sms-wa-bridge # the QR, in the terminal
curl -sS 'https://bridge.tanshu.dev/qr?auth=<AUTH_SECRET>' -o qr.png
Then WhatsApp → Settings → Linked Devices → Link a Device. Two live copies cannot share it: the same linked device can only be connected from one place at a time, and the second one to connect logs the first one out.
Settings
| Variable | Purpose |
|---|---|
podman_user, podman_data_dir, quadlet_dir |
Rootless-Podman contract, duplicated from caddy/vars/default.yml |
container_name |
Container, unit and network prefix — sms-wa-bridge |
host |
bridge.tanshu.dev, unchanged from the Docker era |
image |
localhost/sms-wa-bridge:latest — local, fully qualified, Pull=never |
image_docker_hub |
Where podman load puts an unqualified docker tag; the play re-tags it |
rebuild_image |
false. -e rebuild_image=true forces podman build from files/build/ |
host_directory |
~/podman-data/sms-wa-bridge — all four mounts live under it |
build_dir |
~/podman-data/sms-wa-bridge-build — the copied build context |
container_port |
8080, the port server.js listens on; Caddy proxies to it |
auth_secret |
The X-Auth secret, carried over verbatim so MacroDroid needs only a URL change |
env_file |
~/podman-data/sms-wa-bridge/sms-wa-bridge.env, mode 0600, holds AUTH_SECRET |
health_cmd |
The unit's HealthCmd — wget against /health, the only HTTP client the image has |
caddy_name, caddyfile_path, snippet_block |
The Caddy side, as in every service playbook |
Keep the three podman_*/quadlet_dir vars in sync with
caddy/vars/default.yml — they are duplicated rather
than imported because a play cannot include Caddy's vars file wholesale (it also
sets host_directory, which every service defines for itself).
Running
ansible-playbook -u tanshu sms-wa-bridge/playbook.yml
The play targets emily directly, so the inventory must contain it — the one in
prepare-server/ does, and its ansible.cfg names it, so in practice:
cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml
This play applies only this app's unit, data directory, environment file and
image. The rootless-Podman prerequisites — installing podman, loginctl enable-linger, the low-port sysctl and the Quadlet directory — are the shared
task file at caddy/roles/podman/tasks/main.yaml,
and are not run here. A host that has already run caddy/playbook.yml has
them. (podman build additionally needs buildah, which podman Recommends on
Ubuntu and that role therefore installed.)
systemctl --user enable sms-wa-bridge is neither needed nor possible: the unit
is generated, and Quadlet applies [Install] WantedBy=default.target itself.
--check is a real preview of this play, not a partial deploy: it reports the
data tree, the environment file, the image decision (the two podman image exists probes run even under --check) and the unit, and it stops short of
starting anything. That is deliberate — see the notes on check_mode: false in
roles/sms-wa-bridge — and it is how this
playbook was verified before it was first run for real.
cd prepare-server && ansible-playbook -u tanshu --check ../sms-wa-bridge/playbook.yml
Migrating the data from monoco
Run these from the machine that holds the monoco and emily SSH aliases.
Everything the bridge owns is a file: there is no database dump to take.
What is there, and why it matters:
| Source on monoco | Size | Keep? |
|---|---|---|
/var/lib/sms-wa-bridge/wwebjs_auth/ |
336 MB | Yes — the linked WhatsApp device |
/var/lib/sms-wa-bridge/wwebjs_session/ |
empty | Harmless; carried so the tree matches |
/var/lib/sms-wa-bridge/rules.json |
493 B | Yes — the live routing rules |
/var/lib/sms-wa-bridge/bridge.log |
4 KB | Optional — log history only; the play creates an empty one if it is missing |
Everything under it is root:root, and wwebjs_auth/session is mode 0700,
which is why every read below needs sudo on monoco.
# 0. Look before copying. `ls -ln` shows numeric owners for the reason above.
ssh monoco 'sudo du -sh /var/lib/sms-wa-bridge; sudo ls -ln /var/lib/sms-wa-bridge'
# 1. Bring the image across, and normalise its name. Do this *before* the
# playbook runs, so the play finds the image and does not build one.
set -o pipefail
ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load'
ssh emily 'podman tag docker.io/library/sms-wa-bridge:latest localhost/sms-wa-bridge:latest'
ssh emily 'podman image exists localhost/sms-wa-bridge:latest && echo "image ready"'
# 2. Stop the old container so nothing writes while you copy — a live Chromium
# profile copy can be torn — and leave it stopped. emily is about to become
# the only live copy, and two copies behind the same linked device fight.
ssh monoco 'sudo docker stop sms-wa-bridge'
# 3. Create the destination tree. The playbook creates this too, but the copy
# needs somewhere to land — and it must run *before* the playbook, because
# the app writes a fresh session here the moment it starts.
ssh emily 'mkdir -p ~/podman-data/sms-wa-bridge/wwebjs_auth ~/podman-data/sms-wa-bridge/wwebjs_session'
# 4. Copy the tree. tar streams it across this workstation; rsync cannot copy
# remote-to-remote in one command.
#
# * the source needs sudo — everything there is root-owned, and the session
# directory is not even traversable without it;
# * --no-same-owner is what lands the files owned by tanshu, which is the only
# uid the container can write as (see "Who runs as whom");
# * set -o pipefail matters: without it the pipeline reports only the
# *receiving* tar's status, so a read error or a dropped stream on monoco
# exits 0 and leaves a silently short copy.
set -o pipefail
ssh monoco 'sudo tar -C /var/lib/sms-wa-bridge -cf - .' \
| ssh emily 'tar --no-same-owner -C ~/podman-data/sms-wa-bridge -xf -'
# 5. Verify by content. Same relative paths on both sides, so the aggregate
# digests are comparable — and the hashing needs sudo on monoco too, because
# much of the Chromium profile is mode 0600.
ssh monoco 'cd /var/lib/sms-wa-bridge && sudo find . -type f -print0 | sort -z | sudo xargs -0 -r sha256sum | sha256sum'
ssh emily 'cd ~/podman-data/sms-wa-bridge && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum'
# identical digests = a byte-faithful copy
# (-type f skips the three stale Singleton* symlinks the entrypoint deletes
# anyway; 1707 files, so this takes a few seconds)
# 6. Nothing may be root-owned on the far side: with keep-id:uid=0 the container
# can only write files owned by tanshu. Empty output is the correct answer,
# and the playbook asserts it before it starts anything.
ssh emily 'find ~/podman-data/sms-wa-bridge ! -user tanshu'
# 7. Deploy — after the copy, so the app finds the linked session on first start.
cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml
If the link is slow or drops, do it in two hops instead: resumable, and it shows
progress. --rsync-path="sudo rsync" is what reaches the root-owned source, and
--no-owner --no-group is the rsync spelling of --no-same-owner.
rsync -aP --no-owner --no-group --rsync-path="sudo rsync" \
monoco:/var/lib/sms-wa-bridge/ ~/sms-wa-bridge-copy/
rsync -aP --no-owner --no-group ~/sms-wa-bridge-copy/ \
emily:~/podman-data/sms-wa-bridge/
Rolling back means rolling back all of it: ssh monoco 'sudo docker start sms-wa-bridge' is only meaningful while monoco still holds the session, and the
linked device can only be live in one place. Never run both at once — the
second one to connect logs the first one out, and the one that just lost the
device keeps accepting SMS without ever answering them.
Verification
systemctl --user status sms-wa-bridge
journalctl --user -u sms-wa-bridge -f
podman logs sms-wa-bridge # the QR, once, if it is not linked
# What the playbook checks for you:
podman healthcheck run sms-wa-bridge # /health over HTTP
find ~/podman-data/sms-wa-bridge ! -user tanshu # empty
# The one thing health does not say — is WhatsApp actually linked?
podman exec sms-wa-bridge wget -q -O - http://127.0.0.1:8080/health
# {"waReady":true,"rules":"loaded"}
# The state is where the host can see it, and the mounts are bind mounts:
podman inspect sms-wa-bridge --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
ls -ln ~/podman-data/sms-wa-bridge
# Running as tanshu on the host, as container-root inside — the keep-id mapping.
# `HUSER 1001` is tanshu on emily, and it is the uid every file the bridge writes
# will belong to:
podman top sms-wa-bridge huser,user,args | head -3
Then end to end, which is the only check that proves the whole path — and it sends a real WhatsApp message, so warn the recipients first:
curl -sS https://bridge.tanshu.dev/health
curl -sS -X POST https://bridge.tanshu.dev/sms \
-H 'X-Auth: 64f205031acc01744b6edc23' \
-H 'Content-Type: application/json' \
-d '{"from":"HDFC Bank","text":"Your OTP is 123456"}'
# sent x2
podman logs --tail 5 sms-wa-bridge # and the same line in bridge.log
Finally, reboot the host and confirm the unit returns unattended — without
loginctl enable-linger tanshu it will not, and the failure is silent.
Cutover
One change on each phone: the MacroDroid HTTP action URL becomes
https://bridge.tanshu.dev/sms. The header (X-Auth: 64f205031acc01744b6edc23)
and the body ({"from":"{sms_number}","text":"{sms_text}"}) stay exactly as they
are — the goal of the whole arrangement is that nothing else has to be touched.
bridge.tanshu.dev must resolve to emily before Caddy can obtain a certificate
for it (HTTP-01). If the address moves, check whether Caddy is serving the new
host with the certificate it already holds, or reissuing — see
caddy/readme.md.
Notes and gotchas
- Only one live copy. A linked WhatsApp device is a single session. Running monoco's container and emily's unit at the same time logs one of them out, and the logged-out side then accepts SMS and never answers them. This is the reason the copy commands stop the old container and never start it again.
- Rotating
AUTH_SECRETis a client change, not a server one: every phone sending SMS has to be reconfigured, and an old header gets a401. It is carried over verbatim for that reason, and it is committed in plaintext because it always has been (the Docker-era container took it as an-eflag and the old app readme printed it). latestis not a version. There is no digest pin anywhere in this deployment, and no registry to pin against.docker save/podman loadis what makes the running version reproducible; a rebuild is a new dependency resolution.- Logs do not migrate. monoco's container logged to Docker's
json-filedriver;podman logsandjournalctl --user -u sms-wa-bridgereplace it.bridge.logis the app's own log and does travel, if you copy it. - Log timestamps are UTC, always: the app formats them with
new Date().toISOString(), which ignores the timezone. There is deliberately noTZin the unit — it would change nothing. rules.jsonis live state, not configuration. The play seeds it once and never overwrites it, so editing it on the host applies to the next SMS with no restart. The copy in files/build/ is only the seed.- The
Singleton*locks look alarming and are not. They are stale symlinks from the last host that ran Chromium; the image's entrypoint deletes them on every start. whatsapp-web.jsis an unofficial client linked to whichever WhatsApp account scanned the QR, and it carries a small ban risk. Use a spare number if that matters, and pick whose number the recipients should see — messages come from the linked account.- A container recreate is a few seconds of downtime for this site only: the unit is restarted by a handler when the unit file, the environment file or the image changes, and Caddy returns a connection error for SMS that arrive in that window. A Caddyfile change, by contrast, is only a reload.
/healthand/qrare not behindX-Auththe way/smsis:/healthis public, and/qris gated by the secret in the query string. That is the Docker-era behaviour, kept as-is so the cutover stays a URL change.
Not done here
- Rotating
AUTH_SECRET, and moving the image to a registry so the deployment can pin something. - A systemd user timer for backups. The data directory is plain files owned by
tanshu, so atarof~/podman-data/sms-wa-bridgeis a complete backup — but nothing schedules it here, andbridge.loggrows. - Adding this service to the shared migration notes in docs/: the per-service verification list in docs/podman-compat-audit.md still describes the bridge as a Docker container on monoco.