Your own relay for Alma Remote Access: control the Alma on your desktop from the Alma app on your phone, from anywhere, with no port forwarding, no VPN, and no third party in between.
The project's README from github.com/yetone/alma-relay. As one markdown file: README.md.
English | 简体中文
Your own relay for Alma Remote Access: control the Alma on your desktop from the Alma app on your phone, from anywhere, with no port forwarding, no VPN, and no third party in between.
desktop Alma ──outbound WSS──▶ your relay ◀──HTTPS/WSS── Alma on your phone
The desktop dials out to the relay and keeps the connection open. The phone sends its requests to the relay, which passes them through that connection to the desktop: HTTP, streaming (SSE) and WebSockets. With end-to-end encryption on (the Alma default), the relay only ever sees ciphertext. It stores nothing on disk and never sees your connect code.
Alma ships with a hosted relay (relay.alma.now). Run your own if you want the
traffic on infrastructure you control, a region closer to you, or no
dependency on anyone else's server.
Pick one. Each takes a few minutes.
Or from a clone:
git clone https://github.com/yetone/alma-relay && cd alma-relay
npm install
npx wrangler deploy # prints https://alma-relay.<you>.workers.dev
This runs on the Workers Free plan, using SQLite-backed Durable Objects with
WebSocket hibernation, so an idle pairing costs next to nothing. TLS and a
*.workers.dev hostname come with it. To use your own domain, uncomment
routes in wrangler.jsonc.
You need a server with ports 80 and 443 open and a DNS record pointing at it.
git clone https://github.com/yetone/alma-relay && cd alma-relay
cp .env.example .env # set RELAY_DOMAIN=relay.example.com
docker compose up -d
Caddy obtains and renews the Let's Encrypt
certificate on its own. Your relay is at https://relay.example.com.
docker run -d --restart unless-stopped -p 127.0.0.1:8787:8787 ghcr.io/yetone/alma-relay
# or, without Docker (Node ≥ 20):
npx github:yetone/alma-relay
Then proxy your HTTPS hostname to 127.0.0.1:8787. WebSockets must be allowed
and responses must not be buffered:
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 1h;
client_max_body_size 128m;
}
relay.example.com {
reverse_proxy 127.0.0.1:8787 {
flush_interval -1
}
}
Check it with curl https://relay.example.com/healthz.
https:// address,
and click Use this relay.The phone doesn't need any setup: the pairing link carries the relay address. Phones paired before you switched relays need to scan again.
Set everything through environment variables: in .env for Docker, or with
wrangler secret put NAME (secrets) or vars in wrangler.jsonc (plain
settings) on Cloudflare. All of them are optional.
| Variable | Meaning |
|---|---|
RELAY_REGISTRATION_KEY |
A shared key desktops must present before they can register. Paste it into Alma as the token. |
RELAY_TOKEN_SECRET |
Use per-user signed tokens instead of a shared key. Useful when you run one relay for a team (see below). Takes precedence over RELAY_REGISTRATION_KEY. |
RELAY_TOKEN_MESSAGE |
What a desktop without a valid relay token is told, e.g. how to get one (default: A valid relay token is required). |
PORT / HOST |
Listen address (Node: 8787 / 0.0.0.0). |
MAX_BODY_MB |
Request-body cap in MB (Node: 128. Cloudflare: 100 for desktops on protocol 2, a fixed 24 for older ones). |
MAX_BUFFERED_MB |
Node: cap on all request bodies held in memory at once, in MB (512); over it, 503. Bodies to protocol-2 desktops are streamed and don't count. |
MAX_AGENTS |
Node: most desktops at once (10000). |
MAX_REQUESTS_PER_AGENT / MAX_SOCKETS_PER_AGENT |
Node: concurrent HTTP requests / WebSockets per desktop (256 each). |
MAX_CONNECTIONS |
Node: TCP connection cap for the process (unset: no cap). |
STALL_TIMEOUT_S |
Cut off a phone that reads nothing for this many seconds (Node), or a protocol-2 desktop that takes in none of an upload (both editions) (60). See flow control. |
RELAY_PROTOCOL |
Set to 1 to stop offering protocol 2 (streamed uploads, binary frames) to desktops. Default 2. |
LOCATION_HINT |
Cloudflare only: where tunnels live, for example apac, enam or weur. By default a tunnel is created near the desktop. Set this when the desktop sits behind a proxy far away from the phone. Changing it moves each desktop to a new tunnel the next time it redials; phones find desktops that haven't yet. |
TURN_URLS / TURN_SECRET / TURN_TTL |
Serve TURN credentials for the phone's live view of the desktop browser (WebRTC). |
APNS_KEY_FILE or APNS_KEY, plus APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC |
Phone notifications (see below). |
FCM_SERVICE_ACCOUNT_FILE or FCM_SERVICE_ACCOUNT |
Android phone notifications through Firebase (see below). |
With no key set, anyone who knows your relay's address can open tunnels on
it. They still can't reach your desktop: that takes its 256-bit connect
code. For a personal relay this is usually fine. Set RELAY_REGISTRATION_KEY
to keep strangers from using your server at all.
To share one relay with a team and be able to cut off individual people, set
RELAY_TOKEN_SECRET and mint a token for each person:
RELAY_TOKEN_SECRET=… npm run mint-token -- alice 365 # user id, days valid
# Docker: docker compose exec relay node scripts/mint-token.mjs alice
Tokens are standard HS256 JWTs (aud: "alma-relay", sub, exp), so any JWT
library can mint them too. Rotate the secret to revoke them all.
The iOS app can't keep a connection open while the phone is locked. "Reply finished" and "Alma has a question" therefore arrive through Apple Push, which the relay sends on the desktop's behalf. Apple only accepts these from the key of the team that signs the app. A self-hosted relay therefore can't notify the official Alma iOS app. Everything else works. If you build the iOS app yourself, give the relay your own key:
APNS_KEY_FILE=/path/AuthKey_XXXX.p8 APNS_KEY_ID=XXXX APNS_TEAM_ID=YYYY APNS_TOPIC=your.bundle.id
# Cloudflare: wrangler secret put APNS_KEY < AuthKey_XXXX.p8 (and APNS_KEY_ID, APNS_TEAM_ID)
Android phones are notified through Firebase Cloud Messaging instead. Give the relay the Firebase project's service-account key (Firebase console → Project settings → Service accounts → Generate new private key):
FCM_SERVICE_ACCOUNT_FILE=/path/service-account.json
# Cloudflare: wrangler secret put FCM_SERVICE_ACCOUNT < service-account.json
Like APNs, Firebase only delivers to the app of the project that key belongs
to. A relay with only one of the two keys answers the other kind of phone as
ApnsUnsupported / FcmUnsupported.
Without either key, the relay reports unsupported and the desktop stops asking.
The phone can show the desktop's browser live over WebRTC. On most networks that needs a TURN server. The Docker setup includes coturn:
# in .env:
TURN_SECRET=<a long random string>
TURN_URLS=stun:relay.example.com:3478,turn:relay.example.com:3478?transport=udp,turn:relay.example.com:3478?transport=tcp
docker compose --profile turn up -d
Open UDP/TCP 3478 and UDP 49160-49200 in your firewall. On Cloudflare, run
coturn somewhere else and set TURN_URLS / TURN_SECRET on the Worker. The
relay only issues short-lived credentials. Media never passes through it.
sha256(routing token), and the
routing token is a one-way derivation of the code. Codes, tokens and
traffic are never written to disk or logs.npm install
npm start # Node edition on :8787
npx wrangler dev # Cloudflare edition, locally
npm test # end-to-end suite against the Node edition
npm run test:cloudflare # the same suite against `wrangler dev`
node test/e2e.mjs https://relay.example.com # smoke-test a deployment
| File | |
|---|---|
index.mjs |
Node edition (the ws package is its only dependency) |
cloudflare/worker.mjs |
Cloudflare Workers + Durable Objects edition |
shared.mjs, apns.mjs |
Code both editions use |
docs/PROTOCOL.md |
The wire protocol, if you want to write your own relay or agent |
Your own relay for Alma Remote Access: control the Alma on your desktop from the Alma app on your phone, from anywhere, with no port forwarding, no VPN, and no third party in between.