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.com
browser ─https─▶ Caddy (:443, TLS) ─http─▶ link-relay (127.0.0.1:7080) ◀─wss─ lnk tunnel open

Caddy 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 sshd

Deploy 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-update

The 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.sh

It 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.sh

Roll back

Publish the older build as relay-latest, then on the server:

LINK_ALLOW_DOWNGRADE=1 link-relay-update

The 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 alice

logout 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-relay

To 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.com

It 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.com

fetch-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:

  1. The public releases repo. Create woodpav/link-releases on GitHub, public, with a README. In woodpav/link, Settings → Environments → New environment, make release: 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 secret RELEASES_TOKEN. Run Actions → relay → Run workflow on main once, approve it, then check:
    curl -fsSL https://github.com/woodpav/link-releases/releases/download/relay-latest/SHA256SUMS
  2. The release signing key. Make it as in Rotate the release signing key, save the private key as the release environment's secret RELEASE_SIGNING_KEY, and put the public line in src/shared/release-signers, src/local/cli/install.sh and src/server/deploy/bootstrap.sh. Losing the private key means you have to rotate.
  3. doctl and a reserved IP. The reserved IP stays when servers are replaced, so DNS never changes:
    # 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>
    Point relay.example.com and *.relay.example.com (A records) at the reserved IP. Add AAAA records only once curl -6 https://relay.example.com/ works from outside: a broken one stalls clients that try IPv6 first.
  4. The server:
    src/server/deploy/create-droplet.sh --domain relay.example.com --email you@example.com --user dana --reserved-ip <reserved ip>
    It prints a token for dana. On your Mac, export LINK_RELAY=https://relay.example.com LINK_TOKEN=<token>, then lnk 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.sh

Enable lnk auth login (GitHub)

This lets people get a token by logging in with GitHub, instead of you editing /etc/link/users.

  1. 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.
  2. Turn it on with an invite list (* allows anyone). This re-runs bootstrap, which keeps everything else, and saves LINK_GITHUB_CLIENT_ID and LINK_GITHUB_ALLOW in /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
  3. Add the client secret. It's typed at a prompt, so it stays out of your shell history:
    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'
    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 *.
  4. 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 opens https://github.com/login/device for a code instead. lnk saves 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.

  1. Make a bucket and a key that can write only to it. On Google Cloud, with gcloud (<bucket>, <project> and <region> are yours):
    gcloud 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.com
    The last prints an HMAC key: its access id and secret are the key id and secret below, and the URL is https://storage.googleapis.com/<bucket>. On AWS, make the bucket and an IAM user allowed s3:PutObject and s3:GetObject on it alone; the URL is https://s3.<region>.amazonaws.com/<bucket>.
  2. Optional: expire old backups after 90 days, with a lifecycle rule on the bucket. state-latest is rewritten nightly, so it never expires.
  3. Save the settings on your Mac, for restores:
    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.env
    On AWS, LINK_BACKUP_REGION is the bucket's region, such as us-east-1.
  4. Send them to the server with your public key, and test. Use id_rsa.pub if that's your key. It should print uploaded 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"
  5. 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.

  1. 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
  2. Add its public key as a second line in src/shared/release-signers and in RELEASE_SIGNERS= in both src/local/cli/install.sh and src/server/deploy/bootstrap.sh, in the form link-release namespaces="link-release" ssh-ed25519 AAAA… link-release. A test fails if they differ.
  3. Release, then re-run bootstrap from that checkout on each server (Deploy a change). It installs its keys in /etc/link/release-signers.
  4. In woodpav/link, Settings → Environments → release, set the environment secret RELEASE_SIGNING_KEY to the whole new private key file (cat ~/.ssh/link-release | pbcopy).
  5. 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.

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 --version

What'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.