Files
playbooks/sms-wa-bridge
tanshu f3845bde2b Drop the unused htpasswd file and the secret in the sms readme
Also rename the Outline env template to .env.j2 so it does not look like a dotenv file.
2026-10-18 21:53:29 +05:30
..
2026-10-18 21:35:53 +05:30
2026-10-18 21:35:53 +05:30
2026-10-18 21:35:53 +05:30

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 with docker save | podman load or 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

  1. 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.
  2. 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-bridge the moment it starts, and a first start on an empty directory is a bridge that has to be linked from scratch.
  3. 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.json pins whatsapp-web.js at ^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 copied wwebjs_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: $AUTH_SECRET" \
  -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: <auth_secret> 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_SECRET is a client change, not a server one: every phone sending SMS has to be reconfigured, and an old header gets a 401. 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 -e flag and the old app readme printed it).
  • latest is not a version. There is no digest pin anywhere in this deployment, and no registry to pin against. docker save/podman load is 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-file driver; podman logs and journalctl --user -u sms-wa-bridge replace it. bridge.log is 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 no TZ in the unit — it would change nothing.
  • rules.json is 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.js is 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.
  • /health and /qr are not behind X-Auth the way /sms is: /health is public, and /qr is 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 a tar of ~/podman-data/sms-wa-bridge is a complete backup — but nothing schedules it here, and bridge.log grows.
  • 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.