- Go 99.3%
- Dockerfile 0.7%
Step 3 tells you to back the database up from "an image already on the box",
which is good advice that quietly assumes the image runs as root. Plenty
don't, and `data/` is owned by uid 65532, so the copy dies with
cp: can't create '/data/relay.db.bak-…': Permission denied
The timing is what makes it worth writing down: the copy happens *after*
`docker compose stop`, so the failure lands with the relay already down and
the outage runs until you notice and retry. It cost about two minutes on the
2026-09-14 deploy, with `keinos/sqlite3` picked as the throwaway image.
`--user 0:0` makes the choice of image stop mattering, which is what the step
was reaching for in the first place. Added to the rollback restore too, which
copies the same direction into the same directory and fails the same way.
`redis:7-alpine` as written was never broken — it is on the box and does run
as root. The trap is only sprung by substituting another image, which this
step explicitly invites, so the example is kept and the flag added rather than
pinning the instruction to one image.
Also adds an `ls -l` on the backup, so a failure is caught at the step that
caused it rather than after the restart.
Verified both ways on the box: the same copy fails without the flag and
succeeds with it.
Kept byte-identical to ~/docu/Relay-Server-DEPLOY.md, which is a copy of this
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLcABfRAqD95tvasuTibbn
|
||
|---|---|---|
| cmd/relay | ||
| deploy | ||
| internal | ||
| .dockerignore | ||
| .gitignore | ||
| DEPLOY-RELAY.md | ||
| DEPLOY.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
| relay.env.example | ||
scatto-push-relay
A small relay that turns Pixelfed's Web Push notifications into real Apple Push Notification (APNs) pushes for Scatto, a personal iOS Pixelfed client.
Why this exists
Pixelfed (like Mastodon) delivers real-time notifications via the Web Push protocol: the instance encrypts a payload (RFC 8291) and POSTs it to a URL the client registered ("push service" endpoint). Browsers get that URL for free from their vendor's push infrastructure (FCM, Mozilla's push service, etc). A native iOS app has no such thing built in — Apple's push transport is APNs, which is a different, non-Web-Push protocol; third-party apps can't just point Web Push at Apple's servers directly.
This relay is the push service endpoint: it receives the encrypted Web Push payload from Pixelfed, decrypts it, and forwards it to APNs as a native push. It's single-tenant, personal infrastructure — one relay, one Pixelfed account (pix.hever.de), one device.
Architecture
┌──────────┐ registers p256dh/auth ┌────────────────┐ APNs alert ┌────────┐
│ Scatto │ ───────────────────────▶ │ scatto-push- │ ────────────▶ │ APNs │
│ (iOS) │ keys via /subscribe │ relay (this repo)│ └────────┘
└────┬─────┘ └────────┬────────┘ │
│ registers subscription ▲ POST /wp/{id} │
│ (endpoint + keys) │ (encrypted, RFC 8291) │ push
▼ │ ▼
┌──────────┐ notification happens (like, ┌──────────────┐ ┌──────────┐
│ Pixelfed │ ─────────────────────────────▶│ │ │ device │
│ (pix. │ follow, comment, etc.) │ (Pixelfed's │ └──────────┘
│ hever.de)│ │ own push │
└──────────┘ │ sender) │
└──────────────┘
- Scatto asks iOS for a device token (
registerForRemoteNotifications). - Scatto calls
POST /subscribeon this relay with that device token. The relay generates a fresh P-256 keypair + auth secret for the subscription (RFC 8291 §3.1) and returns{endpoint, p256dh, auth}. - Scatto registers those with Pixelfed via
POST /api/v1/push/subscription(the Mastodon-compatible Push API). - From then on, whenever something happens on the account, Pixelfed
encrypts a payload and POSTs it to this relay's
endpoint(/wp/{id}). - The relay decrypts it with the stored private key + auth secret,
extracts
title/body, bumps the subscription's badge counter, and sends a native APNs alert to the stored device token carrying that badge number plus any deep-link ids from the payload.
Badge counts
The badge is tracked server-side, in the relay, rather than by the app
polling for an unread count. Each subscription owns a badge_count column;
every forwarded push increments it and puts the new value in the APNs
aps.badge field. That means the icon badge is correct the instant the
notification arrives, with the app closed and without it ever waking up —
iOS applies aps.badge itself, so this needs no client code at all.
The app clears it with POST /subscribe/{id}/read when the user opens their
notifications list, and can suppress it entirely with
POST /subscribe/{id}/badges. The counter is per subscription, so two devices
on one account keep independent badges and clearing one leaves the other
alone.
On its own the counter measures the wrong thing. It knows how many pushes the
relay has forwarded since the app last said "read", which is not the same
quantity as "notifications you have not seen" — reading in the Pixelfed web UI
moves the second and not the first. The two had no way to converge, and since
iOS applies aps.badge before any app code runs, the relay's figure is the one
on screen whenever the app is closed. The drift was therefore both visible and
permanent: recovering from it meant reinstalling the app.
So the app reconciles it. Whenever Scatto recomputes its unread count — on
launch, on foreground, on opening the notifications list, on a background
refresh — it hands each account's real figure to
POST /subscribe/{id}/unread. Increments then continue from truth rather than
from a tally of their own, and a badge applied while the app is closed is right
rather than merely plausible.
The reconciliation is one-directional and best-effort. The relay never asks
Pixelfed anything; it is told. A relay older than the route answers 404 and
the app carries on, which is what allows the two to be deployed in either
order.
Deep-link ids
Pixelfed's payload may carry status_id and account_id. The relay copies
whichever are present into the APNs payload as top-level custom keys
alongside aps, which iOS hands the app as
UNNotificationContent.userInfo, so tapping a notification can open the
post or profile it refers to. They arrive as strings, not numbers:
Pixelfed's ids are large enough to lose precision as JSON doubles, and a
client parsing them as numbers would silently deep-link to the wrong post.
A follow and a follow request have no status_id, and a DM has neither a
public status nor (yet) a conversation view in Scatto, so all three
deep-link to the sender's profile.
Naming the recipient
Both ids above describe the other party — whoever liked, followed or wrote. Neither says which account was notified, and a device signed in to several accounts holds a subscription per account, so a tapped push gave the app no way to tell which one it belonged to: it opened under whatever account was already active, asked that account's server for an id belonging to another, and either showed a dead error screen or — when the id happened to exist there too, which is the common case on a single instance — a plausible and entirely wrong profile, with no error to give it away.
The relay already knows the answer. Every subscription is created with an
opaque account value (see POST /subscribe), which is what keeps one
device's accounts from evicting one another. It is now also forwarded as a
top-level account key, letting the app compare before it navigates and
offer to switch accounts rather than failing.
The relay still never interprets the value; it echoes back exactly what was subscribed with. A subscription created without one — a client predating account scoping — omits the key entirely, so its payload is byte-for-byte what it was before.
The relay does not interpret notification_type beyond picking a sound and
deciding whether to collapse; anything the instance sends is forwarded. That
is why follow_request needed no relay change when the fork started sending
it. The reverse also holds: the relay's reblog/share handling is ready
and has never run, because nothing sends a boost push. Closing that is
server-side work in the Pixelfed fork, not here.
Decryption happens server-side, on the relay, not on the device via a Notification Service Extension. That's a deliberate simplification: this relay is infrastructure the app owner fully controls, running for exactly one user, so there's no meaningful trust boundary being crossed by having it see plaintext notification text (e.g. "vinz liked your photo") before forwarding it on. It trades a small amount of server-side trust for a much simpler iOS client (no extra app extension target).
API
POST /subscribe
Called by Scatto when the user enables push notifications — once per account, not once per device, so a phone signed in to two accounts registers twice.
Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>
Request:
{
"device_token": "<hex APNs device token>",
"label": "vinz-iphone",
"account": "vinz@scatto.social"
}
account identifies which signed-in account the subscription belongs to. The
relay never interprets it; a fully-qualified handle is the obvious choice, but
any value stable per account will do so long as two accounts never share one.
Omitting it is allowed and reproduces the old single-subscription-per-device
behaviour, which is almost certainly not what you want — see below.
Response 201:
{
"subscription_id": "…",
"endpoint": "https://push.scatto.social/wp/…",
"p256dh": "<base64url>",
"auth": "<base64url>"
}
endpoint, p256dh, and auth map directly onto the fields Pixelfed's
POST /api/v1/push/subscription expects (subscription[endpoint],
subscription[keys][p256dh], subscription[keys][auth]).
One subscription per account per device. Registering replaces any existing
subscription with the same device_token and account. Re-registering is
routine — an app reinstall, or push toggled off and on — and each call must not
leave the previous row behind. Those orphans are not merely untidy: every one
stays a live push endpoint holding a device token, so anyone who kept an old
endpoint URL could still push to the device.
The account half of that key is not cosmetic. Replacement used to be keyed on
device_token alone, which gave a device exactly one slot across every account
and instance it was signed in to: enabling push for a second account destroyed
the first account's endpoint, and the losing side went silent with nothing on
the device to explain why — its next delivery just 404'd. A client that omits
account puts every registration in the same "" bucket and gets that old
behaviour back.
Rows per device are capped (10). Per-account scoping removed the bound the unscoped replacement used to provide, and each row is a live endpoint carrying that device's token; past the cap the oldest are evicted.
DELETE /subscribe/{id}
Called by Scatto when the user disables push or removes the account.
Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204.
(Scatto should also call Pixelfed's DELETE /api/v1/push/subscription
first — this only tears down the relay's half.)
POST /subscribe/{id}/read
Called by Scatto when the user opens the notifications list. Resets that subscription's badge counter to zero.
Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204.
Clearing an id the relay no longer knows about is also 204, not 404 —
same as DELETE /subscribe/{id}. A client tidying up a subscription the
server has already dropped has nothing to fix, so there is no error worth
reporting.
POST /subscribe/{id}/unread
Called by Scatto whenever it recomputes an account's unread count. Sets that
subscription's badge counter outright, so the next push counts on from it.
Body {"count": 7}.
Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204, including
for an unknown id, matching the other subscription routes.
This is the general case of /read, which is the same statement with the
count fixed at zero. It exists because the relay can only count what it has
forwarded, and that quantity drifts from the server's own notion of unread the
moment anything is read outside the app — see Badge counts above.
Per subscription rather than per device: the relay already sums a device's subscriptions when it sends, so each account reports only its own count and the total takes care of itself.
Negative counts are clamped to zero rather than rejected. APNs refuses a negative badge, and one bad call should not leave a subscription poisoned.
Not to be confused with /badges below, which is a different question — that
one is whether to show a badge at all, this one is what it should say.
POST /subscribe/{id}/badges
Called by Scatto when the user changes the "show application badge"
preference. Body {"enabled": true|false}.
Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204, including
for an unknown id, matching the other subscription routes.
This has to live server-side because iOS applies aps.badge from the push
payload at the OS level, before any app code runs and even while the app is
closed — an app-local preference cannot suppress it. When disabled the relay
omits aps.badge; the alert, sound and deep-link data are unaffected.
The counter keeps incrementing while badges are suppressed, so re-enabling shows the true unread count rather than resuming from zero.
POST /wp/{id}
Called by Pixelfed, never by Scatto directly. Body is an RFC 8291-encrypted
Web Push payload (aes128gcm content coding). No bearer auth on this route
— per the Web Push protocol, the unguessable {id} in the URL is the
credential, same as any real push service endpoint.
Decrypted payload fields the relay reads:
{
"notification_type": "like",
"title": "New Like",
"body": "vinz liked your post",
"status_id": "988149955134320972",
"account_id": "961576165188272129"
}
status_id/account_id are optional; null or absent both mean "no
deep-link target" and are simply omitted from the APNs payload. Unknown
fields are ignored, so Pixelfed can add more without a relay change.
What reaches APNs is those fields plus account, which comes from the
subscription rather than from this body — Pixelfed has no idea a device holds
several, so only the relay can say which one it just pushed to:
{
"aps": { "alert": { "title": "…", "body": "…" }, "badge": 3, "sound": "…" },
"notification_type": "follow_request",
"account_id": "961576165188272129",
"account": "988381240561664001@scatto.social"
}
A failure to bump the badge counter is logged but does not fail the request — the notification still goes out, just without a badge. Losing the alert entirely over a counter is the worse outcome.
Delivery failures are split by whether they're worth retrying. A transient
APNs error (503, throttling, a network blip) answers 502: Pixelfed retries
the same push, and the relay first reverts the badge bump it made for this
attempt, so a notification that took three tries to land still counts as one,
not three. A permanent rejection — BadDeviceToken, Unregistered
(the app was uninstalled), or DeviceTokenNotForTopic — means the device token
will never accept another push, so the relay deletes the subscription and
answers 404, the "dead endpoint" signal that tells Pixelfed to stop retrying
and forget it. Otherwise a stale token would fail every push forever while the
orphaned subscription lingered.
Request bodies are capped (64 KB here, 8 KB on the subscription routes) —
comfortably above any real payload, but low enough that a leaked endpoint URL
can't be used to exhaust memory with an oversized body.
Rate limited per subscription id: burst of 20, refilling at one every two
seconds, answering 429 beyond that. The endpoint URL is the only credential
this route has, so a URL that leaked — an old subscription, a log line, a
database copy — could otherwise be used to hammer the device indefinitely.
Buckets are only created for ids that exist in the store, so unknown ids
can't grow the limiter's memory.
A note on SUBSCRIBE_TOKEN
The token is shared with the Scatto app and therefore ships inside the app
binary; treat it as public. It gates creating subscriptions, which is what
it is good for. It is deliberately not the only thing protecting
DELETE /subscribe/{id} or POST /subscribe/{id}/read — both also require
the subscription id, which is 128 bits of unguessable randomness and never
leaves the device that owns it. If this relay is ever pointed at more than
one user, that reasoning stops holding and the token needs to become a real
per-account credential.
GET /healthz
Plain 200 ok — used for deploy verification and uptime checks.
Repo layout
cmd/relay/ main.go — wiring only
internal/webpush/ RFC 8291 / RFC 8188 receiver-side crypto (keygen + decrypt)
internal/store/ SQLite-backed subscription storage
internal/apns/ APNs token-auth client (wraps sideshow/apns2)
internal/server/ HTTP handlers
internal/config/ env-var config loading
Dockerfile multi-stage build -> distroless nonroot image
docker-compose.yml the deployed service definition
relay.env.example env template (copy to ./relay.env, not committed)
deploy/ nginx vhost snippet for host nginx + certbot
Local development
go build ./...
go test ./...
internal/webpush has a self-contained encrypt/decrypt round-trip test
(decrypt_test.go) that independently implements the RFC 8291 sender
role to validate the receiver logic this service actually ships — this is
the part worth trusting before deploying, since a subtle bug there just
means silently-dropped notifications with no obvious symptom.
internal/store covers the badge counter, including that it survives a
restart, stays independent per subscription, and that Open upgrades a
database created before badge support existed.
The binary is CGO-free (modernc.org/sqlite is a pure-Go SQLite driver),
so it cross-compiles trivially and needs no libc in its container image:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o relay ./cmd/relay
Changing the schema
store.Open creates the table with CREATE TABLE IF NOT EXISTS, which does
nothing at all to a database that already has that table. Adding a
column to that statement therefore only affects fresh installs; a deployed
relay keeps its old schema and every query touching the new column fails at
runtime with no such column.
New columns need an explicit migration alongside the CREATE TABLE, run on
every start and safe to repeat — see ensureColumn, which checks
PRAGMA table_info and issues an ALTER TABLE ... ADD COLUMN only when the
column is missing (used for both badge_count and badges_enabled).
TestOpenAddsBadgeCountToPreExistingDatabase and
TestOpenAddsBadgesEnabledToPreExistingDatabase each build a pre-migration
database and assert the upgrade, so this stays honest.
Deployment
Runs as a Docker Compose service (Dockerfile builds a multi-stage,
distroless, nonroot image — no shell, no package manager, nothing beyond
the static binary) behind host nginx + certbot, matching how the rest of
this VPS's services are already deployed. See DEPLOY.md for the full
runbook, docker-compose.yml for the service definition, and
deploy/nginx-push.scatto.social.conf for the vhost.