Get Started
The relay gives each tunnel its public address,
https://<app>.<user>.<domain>. This page is for whoever runs one; the
examples use relay.example.com for its domain. The scripts are in
src/server/deploy.
# From anywhere
# prints "ok <version>" when the relay is up
curl https://relay.example.com/_link/health
# On the server (ssh root@relay.example.com)
# relay and update logs
journalctl -u link-relay -u link-relay-update -f
# update now instead of waiting 5 minutes
systemctl start link-relay-update
# who is connected
link-relay admin tunnels
# cut someone off now
link-relay admin block alice
# back up now
systemctl start link-backup
# On your Mac
# replace the server
src/server/deploy/rebuild.sh --old <old droplet ip> --reserved-ip <reserved ip> --domain relay.example.combrowser ─https─▶ Caddy (:443, TLS) ─http─▶ link-relay (127.0.0.1:7080) ◀─wss─ lnk tunnel openCaddy handles HTTPS and gets a Let's Encrypt certificate for each
hostname on first use, once the relay approves it. link-relay routes
each hostname to its user's lnk. The server is disposable: one
command sets one up, and one command replaces it without losing users,
certificates or its IP.
Day to day
Check it's healthy
# on the server: "ok <version>"
curl localhost:7080/_link/health
# a failed update or backup shows here
systemctl --failed
systemctl list-timers link-relay-update.timer link-backup.timer
# the last updates
journalctl -u link-relay-update -n 20 --no-pager
# who is banned from SSH
fail2ban-client status sshdDeploy a change
Merge to main. .github/workflows/relay.yml builds the relay and
publishes it, with the deploy files, to the relay-latest release of the
public repo woodpav/link-releases. Every server checks it every 5
minutes and installs a new build only if it is signed, newer than the
installed one, and healthy after a restart; otherwise it rolls back. To
update now:
ssh root@relay.example.com systemctl start link-relay-updateThe updater replaces only the link-relay binary. A change to the
Caddyfile, the systemd units or the helper scripts in
src/server/deploy/files reaches a server only when you re-run
bootstrap. It keeps users, certificates and settings, and it also
re-applies the firewall and hardening. Run it from link-core/ in your
checkout of main, never from a download: it carries the release key
that everything it installs is checked against.
# this checkout's version: the server never installs an older relay
V=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
ssh root@relay.example.com "bash -s -- --domain relay.example.com --email you@example.com --min-version $V" < src/server/deploy/bootstrap.shIt installs the release's deploy files only if they match its
SHA256SUMS and that is signed by a key in the script. With
--min-version it refuses a release older than that version, and keeps
it in /etc/link/min-relay-version as a floor the updater holds to,
until a higher one is given. create-droplet.sh and rebuild.sh pass
their checkout's version themselves, so run them from a checkout of
main: one whose version isn't released yet is refused. To try deploy
files before they're released, copy them over and pass --from, which
installs them as they are, with no signature check:
scp -r src/server/deploy/files root@relay.example.com:/root/link-files
ssh root@relay.example.com 'bash -s -- --from /root/link-files' < src/server/deploy/bootstrap.shRoll back
Publish the older build as relay-latest, then on the server:
LINK_ALLOW_DOWNGRADE=1 link-relay-updateThe timer's own runs refuse a build older than the installed relay, or
than the floor in /etc/link/min-relay-version. A build that failed its
health check is rolled back by itself and skipped from then on.
Manage users
On the server:
# every user: tokens, open tunnels, ok / blocked / not invited
link-relay admin users
# tunnel, user, uptime, requests, lnk version, IP
link-relay admin tunnels
# disconnect one tunnel; lnk stops
link-relay admin kick my-app.alice
# disconnect alice and refuse her connects and logins
link-relay admin block alice
# undo block
link-relay admin unblock alice
# sign out every machine alice logged in, when she lost them all
link-relay admin logout alicelogout is the way back in for someone who lost every machine signed
in: their next lnk auth login would wait for one of those to confirm
it. Their tunnels already open stay up until they close (kick ends
one). Check it's them first, as you would for any account recovery.
block needs no restart, lasts across restarts and survives a GitHub
rename. It's kept in /var/lib/private/link/blocked, which is in the
snapshot. link-relay admin talks to the relay on the same machine
(--relay, default http://127.0.0.1:7080); the relay answers admin
requests only from loopback.
Add a user who logs in with GitHub: add their GitHub username to
LINK_GITHUB_ALLOW in /etc/link/relay.env, run systemctl restart link-relay, and they run lnk auth login. Taking them out of the list
and restarting ends their tokens at once, open tunnels included.
Add a user without GitHub login: this appends the token's hash to the users file and prints the token once. Give it to them.
t=$(openssl rand -hex 24); echo "alice sha256:$(printf %s "$t" | sha256sum | cut -d' ' -f1)" >> /etc/link/users && systemctl restart link-relay && echo "$t"To remove one, delete their line from /etc/link/users and restart.
Open signups: set LINK_GITHUB_ALLOW=* in /etc/link/relay.env,
plus anyone to invite by name, and restart. It needs the client secret
(step 3 of Enable lnk auth login):
without it the relay refuses to start. What newcomers get (account age,
reserved names, a daily cap, the visitor warning) is in
the tunnel's security;
tune it with LINK_GITHUB_MIN_AGE_DAYS and LINK_SIGNUPS_PER_DAY.
Change a setting
Settings live in /etc/link/relay.env (root-only), which the systemd
unit loads. Every one is in
CONFIGURATION.md. Edit the
file and restart, or re-run bootstrap with its flag:
# a message to every lnk that connects
echo 'LINK_NOTICE=maintenance at 22:00 UTC' >> /etc/link/relay.env && systemctl restart link-relayTo send browsers on the bare domain to the website, re-run bootstrap
with --website https://www.example.com. /_link/* and /install.sh
stay on the relay.
Limits (requests, tunnels, body sizes, timeouts) are compiled into the relay: see the tunnel's security.
Recover from a loss
Replace the server
For a broken server, a new region or a new size:
src/server/deploy/rebuild.sh --old <old droplet ip> --reserved-ip <reserved ip> --domain relay.example.comIt exports the old server's state, keeps a copy in the current folder,
creates and bootstraps a new droplet with that state, and moves the
reserved IP to it. Agents reconnect by themselves and keep their URLs.
Any other flag goes to create-droplet.sh (--region, --size, ...).
The old droplet keeps running, so you can switch back by reassigning the IP. Delete it once the new one works:
doctl compute droplet list --tag-name link-relay
doctl compute droplet delete <old id>Restore from a backup
When the old server is gone, restore its last off-site backup. Needs
age (brew install age) and the backup settings on your Mac (see
Set up off-site backups):
# writes ./link-state-latest.tar.gz
src/server/deploy/fetch-backup.sh --env ~/.config/link/backup.env
src/server/deploy/create-droplet.sh --state link-state-latest.tar.gz --reserved-ip <reserved ip> --domain relay.example.comfetch-backup.sh decrypts with ~/.ssh/id_ed25519; pass --identity <file> for another key and --name state-<time>.tar.gz.age for an older
backup. Without an off-site backup the accounts are lost: users log in
again, and users-file tokens have to be reissued.
A snapshot is only as trustworthy as the bucket it came from: whoever can write there can upload one of their own, with its own users and settings. A restore never takes the release key or the version floor from it; bootstrap writes both from your checkout after the import.
To copy the server's local snapshots to your Mac:
scp 'root@relay.example.com:/var/backups/link/*' ~/relay-backups/Set it up
Set up a new relay
Once, before the first server:
- The public releases repo. Create
woodpav/link-releaseson GitHub, public, with a README. Inwoodpav/link, Settings → Environments → New environment, makerelease: Deployment branches and tags → Selected branches →main, and Required reviewers → yourself. The jobs that sign and publish run only there. Make a fine-grained token with access to that repo only and Contents: Read and write, and save it as that environment's secretRELEASES_TOKEN. Run Actions → relay → Run workflow onmainonce, approve it, then check:curl -fsSL https://github.com/woodpav/link-releases/releases/download/relay-latest/SHA256SUMS - The release signing key. Make it as in
Rotate the release signing key,
save the private key as the
releaseenvironment's secretRELEASE_SIGNING_KEY, and put the public line insrc/shared/release-signers,src/local/cli/install.shandsrc/server/deploy/bootstrap.sh. Losing the private key means you have to rotate. doctland a reserved IP. The reserved IP stays when servers are replaced, so DNS never changes:Point# API token: DigitalOcean → API → Generate brew install doctl && doctl auth init # needs at least one key; the scripts use all of them doctl compute ssh-key list # prints the IP doctl compute reserved-ip create --droplet-id <droplet id>relay.example.comand*.relay.example.com(A records) at the reserved IP. Add AAAA records only oncecurl -6 https://relay.example.com/works from outside: a broken one stalls clients that try IPv6 first.- The server:
It prints a token for
src/server/deploy/create-droplet.sh --domain relay.example.com --email you@example.com --user dana --reserved-ip <reserved ip>dana. On your Mac,export LINK_RELAY=https://relay.example.com LINK_TOKEN=<token>, thenlnk tunnel open 3000 --public.
Then enable lnk auth login and
set up off-site backups.
Without DigitalOcean: bootstrap.sh works on any Ubuntu 24.04
server with a public IP. Run it as root from your checkout with
--domain, --email and --user:
ssh root@<server ip> 'bash -s -- --domain relay.example.com --email you@example.com --user dana' \
< src/server/deploy/bootstrap.shEnable lnk auth login (GitHub)
This lets people get a token by logging in with GitHub, instead of you
editing /etc/link/users.
- Make a GitHub OAuth app: github.com → Settings → Developer
settings → OAuth Apps → New OAuth App. Name: Link. Homepage URL:
https://relay.example.com. Authorization callback URL:https://relay.example.com/_link/login/callback. Tick Enable Device Flow, register, copy the Client ID, and generate a client secret. - Turn it on with an invite list (
*allows anyone). This re-runs bootstrap, which keeps everything else, and savesLINK_GITHUB_CLIENT_IDandLINK_GITHUB_ALLOWin/etc/link/relay.env:ssh root@relay.example.com 'bash -s -- --github-client-id <client id> --github-allow dana,alice' \ < src/server/deploy/bootstrap.sh - Add the client secret. It's typed at a prompt, so it stays out of
your shell history:
With it, logins are approved on the relay's own page. Without it, the relay uses GitHub's device flow, which is fine for an invite list but refuses to start with
ssh root@relay.example.com 'read -rsp "Client secret: " s && echo && echo "LINK_GITHUB_CLIENT_SECRET=$s" >> /etc/link/relay.env && systemctl restart link-relay'*. - Log in on any machine with
lnk auth login. It opens a link: GitHub asks you to authorize Link, then the relay's page shows the device and address and asks you to approve. Without the secret it openshttps://github.com/login/devicefor a code instead.lnksaves the token in~/.config/lnk/config.toml. Once an account has a machine signed in, its next login on another machine also waits for one of them to confirm it (lnk auth approve), with or without the secret.
Users made this way are saved in /var/lib/private/link/users, one line
per device: its token's hash, the device name and the GitHub account id.
They're in the nightly snapshot. Who may log in and which names they can
claim is in the tunnel's
security.
Set up off-site backups
Every night the server snapshots its state (users, tokens, settings,
certificates) into /var/backups/link and keeps 7. By default that's
the only copy: losing the server loses every account. Set this up once
so each snapshot is also encrypted with age to your public key and
uploaded to a bucket. The server can't read old backups, and neither can
anyone with the bucket.
Any bucket that speaks S3's API works: Google Cloud Storage, AWS S3, or another S3-compatible store. The snapshots are a few KB, so the cost is close to nothing.
- Make a bucket and a key that can write only to it. On Google
Cloud, with
gcloud(<bucket>,<project>and<region>are yours):The last prints an HMAC key: its access id and secret are the key id and secret below, and the URL isgcloud storage buckets create gs://<bucket> --project <project> --location <region> --uniform-bucket-level-access gcloud iam service-accounts create link-backup --project <project> gcloud storage buckets add-iam-policy-binding gs://<bucket> --member serviceAccount:link-backup@<project>.iam.gserviceaccount.com --role roles/storage.objectUser gcloud storage hmac create link-backup@<project>.iam.gserviceaccount.comhttps://storage.googleapis.com/<bucket>. On AWS, make the bucket and an IAM user alloweds3:PutObjectands3:GetObjecton it alone; the URL ishttps://s3.<region>.amazonaws.com/<bucket>. - Optional: expire old backups after 90 days, with a lifecycle rule
on the bucket.
state-latestis rewritten nightly, so it never expires. - Save the settings on your Mac, for restores:
On AWS,
mkdir -p ~/.config/link && cat > ~/.config/link/backup.env <<'EOF' LINK_BACKUP_URL=https://storage.googleapis.com/<bucket> LINK_BACKUP_REGION=auto LINK_BACKUP_KEY_ID=<access id> LINK_BACKUP_SECRET=<secret> EOF chmod 600 ~/.config/link/backup.envLINK_BACKUP_REGIONis the bucket's region, such asus-east-1. - Send them to the server with your public key, and test. Use
id_rsa.pubif that's your key. It should printuploaded state-….tar.gz.age:scp ~/.config/link/backup.env root@relay.example.com:/etc/link/backup.env scp ~/.ssh/id_ed25519.pub root@relay.example.com:/etc/link/backup-recipients ssh root@relay.example.com "chmod 600 /etc/link/backup.env && systemctl start link-backup && journalctl -u link-backup -n 3 --no-pager" - Check you can restore:
src/server/deploy/fetch-backup.sh --env ~/.config/link/backup.env && tar -tzf link-state-latest.tar.gz && rm link-state-latest.tar.gz
Keep a second decryption key. Only the private halves of the keys in
/etc/link/backup-recipients open the backups. So as not to depend on
your Mac alone, run age-keygen -o link-backup.key, store that file in
your password manager, and append its age1… public key as another line
of /etc/link/backup-recipients. Restore with fetch-backup.sh --identity link-backup.key.
A failed upload fails the link-backup unit (systemctl --failed) and
keeps the local snapshot.
Rotate the release signing key
Every release's SHA256SUMS is signed. Servers, lnk upgrade and
install.sh install nothing without a valid signature, so someone who
gets into the releases repo or its token still can't push code.
- Make the new key on your Mac and keep the private half in a password
manager:
ssh-keygen -t ed25519 -N "" -C link-release -f ~/.ssh/link-release - Add its public key as a second line in
src/shared/release-signersand inRELEASE_SIGNERS=in bothsrc/local/cli/install.shandsrc/server/deploy/bootstrap.sh, in the formlink-release namespaces="link-release" ssh-ed25519 AAAA… link-release. A test fails if they differ. - Release, then re-run bootstrap from that checkout on each server
(Deploy a change). It installs its keys in
/etc/link/release-signers. - In
woodpav/link, Settings → Environments → release, set the environment secretRELEASE_SIGNING_KEYto the whole new private key file (cat ~/.ssh/link-release | pbcopy). - Remove the old line in a later release.
If a key leaks, remove its line at once, set a new key as in step 4,
release, then re-run bootstrap from that checkout on each server and
have users reinstall. Bootstrap refuses relay-latest until a release
signed by a key it still trusts replaces it.
Run link-relay by hand
On a server, systemd runs link-relay.service with the settings in
/etc/link/relay.env. For development, see
running it locally.
# dev mode: 127.0.0.1:7080, the single user `dev`
link-relay
link-relay --domain relay.example.com --users-file /etc/link/users --state-dir /var/lib/link
link-relay --versionWhat's on a server
link-state exports and imports the state: /etc/link (users,
settings), /var/lib/private/link (login users, blocks, share links,
the hosts approved for certificates) and Caddy's certificates.
link-backup runs link-state backup nightly. If you lose your SSH
key, use the droplet's Recovery Console in DigitalOcean.
What bootstrap and the updater install, and the checks before they do, are in security.md, with how the server is hardened. Every setting and variable is in CONFIGURATION.md, and why it works this way in decisions.md.
