Deploy (Synology NAS) #
The avatar updater runs as a single detached, self-scheduling container on
the NAS: it updates the avatar at 04:00 / 09:00 / 14:00 / 18:00 / 22:00 via
schedule.sh, restarts on boot, and fails soft so a bad run never matters.
Files live at /volume1/docker/bsky-avatar/ on the NAS.
First-time install #
# 1. Build the image (from the synced repo dir)
sudo docker build -t bsky-avatar /volume1/docker/bsky-avatar
# 2. Create the secrets file and fill it in
cp /volume1/docker/bsky-avatar/.env.example /volume1/docker/bsky-avatar/.env
# edit .env: BSKY_APP_PASSWORD (Bluesky app password) + HASS_TOKEN (HA long-lived token)
# 3. Start the scheduled container
sudo docker run -d --name bsky-avatar --restart unless-stopped \
--env-file /volume1/docker/bsky-avatar/.env \
-v /volume1/docker/bsky-avatar/config:/app/config \
-v /volume1/docker/bsky-avatar/state:/app/state \
-v /volume1/docker/bsky-avatar/deploy:/app/deploy \
--entrypoint sh bsky-avatar /app/deploy/schedule.sh
(deploy/ is also baked into the image via the Dockerfile; the bind-mount lets
you tweak schedule.sh without a rebuild and keeps older images working.)
The location sink #
A second container from the same image, so the phone can push its position over WireGuard even when Home Assistant (or the whole home LAN) is down.
# token the phone authenticates with — put the same value in .env
openssl rand -hex 32
sudo docker run -d --name bsky-avatar-sink --restart unless-stopped \
--env-file /volume1/docker/bsky-avatar/.env \
-p 8477:8477 \
-v /volume1/docker/bsky-avatar/config:/app/config \
-v /volume1/docker/bsky-avatar/state:/app/state \
--entrypoint node bsky-avatar /app/src/sink.js
It shares the state/ volume with the updater: the sink writes
state/location.json, the updater reads it on its next run.
Check it, from the NAS and then from the phone over WireGuard:
curl -s http://localhost:8477/healthz # -> ok
curl -s "http://<nas-lan-ip>:8477/location?token=$LOC_SINK_TOKEN" # last position
Keep it on the LAN. WireGuard already carries the phone onto the home subnet, so the sink needs no port forward, no tunnel, and no public DNS. Do not expose it.
On the phone, OwnTracks in HTTP mode is the least
work: set the URL to http://<nas-lan-ip>:8477/, leave the username as anything,
and put the token in the password field (the sink accepts it as HTTP Basic).
Anything that can POST {"lat": .., "lon": ..} with a bearer token works too.
After editing config #
config/*.yaml (holidays, overrides, settings) is bind-mounted and re-read on
every scheduled run — no restart needed.
After editing secrets (.env) #
--env-file is read only when the container is created, not on docker restart. So to pick up new .env values you must recreate the container
(not restart it):
sudo docker rm -f bsky-avatar
# then re-run the `docker run -d ...` line from "First-time install"
Updating the code #
Re-sync the repo to /volume1/docker/bsky-avatar/, then rebuild and recreate
both containers (the code lives inside the image; only config/ and
state/ are bind-mounted):
sudo docker build -t bsky-avatar /volume1/docker/bsky-avatar
sudo docker rm -f bsky-avatar bsky-avatar-sink
# re-run the two `docker run -d ...` lines above
Useful #
sudo docker logs --tail 50 bsky-avatar # see recent runs + next-run countdown
sudo docker logs --tail 20 bsky-avatar-sink # see positions the phone has pushed
sudo docker exec bsky-avatar node src/index.js --dry-run # render without uploading
sudo docker exec bsky-avatar node src/index.js --force # force an immediate upload
Optional: Signal mirroring #
Mirrors the avatar to your Signal profile via a signal-cli-rest-api sidecar.
Linking needs normal mode; serving runs in json-rpc mode (reliable for
a linked account — normal mode cold-starts a JVM per request and is flaky).
# 1. Start the sidecar in NORMAL mode for linking (8080 is often taken; use 8099)
sudo docker run -d --name signal-api --restart unless-stopped \
-p 8099:8080 -v /volume1/docker/signal-api:/home/.local/share/signal-cli \
-e MODE=normal bbernhard/signal-cli-rest-api:latest
# 2. Link your account. The /v1/qrcodelink request returns the QR ~8s in and
# HOLDS THE CONNECTION ~30s — keep it open and scan within that window.
# Be on Signal -> Settings -> Linked Devices -> Link New Device FIRST, then:
( curl -s "http://localhost:8099/v1/qrcodelink?device_name=bsky-avatar" -o /tmp/qr.png & ) ; sleep 8
# Copy /tmp/qr.png to a screen and scan it immediately (before the ~30s elapses).
# 3. Confirm the linked number appears
curl -s "http://localhost:8099/v1/accounts" # -> ["+316..."]
# 4. Switch to JSON-RPC for serving (the linked account persists in the volume).
# NOTE: AUTO_RECEIVE_SCHEDULE is INCOMPATIBLE with json-rpc — never set it
# (it crash-loops the container).
sudo docker rm -f signal-api
sudo docker run -d --name signal-api --restart unless-stopped \
-p 8099:8080 -v /volume1/docker/signal-api:/home/.local/share/signal-cli \
-e MODE=json-rpc bbernhard/signal-cli-rest-api:latest
Then set SIGNAL_API_URL (e.g. http://<host-ip>:8099), SIGNAL_NUMBER (+E.164)
and SIGNAL_PROFILE_NAME in .env, and recreate the bsky-avatar container
(it's an --env-file change). signalMirror in settings.yaml chooses events
(only push on bg/holiday change) or all (mirror everything).