AZiT

Deploys that can only write files

A deploy user locked to rsync into the web root, and deploys from the laptop only.

Why Web server, on the tour

On this page 4 sections

The site is built on the laptop and copied to the server with rsync, as a user whose key can do exactly one thing: write files into the web root. No shell, no reading anything back, no forwarding.

1. The deploy user#

server
sudo useradd --create-home --shell /bin/sh --comment "site deploys" deploy
sudo install -d -o deploy -g deploy -m 755 /var/www/homelab
sudo apt install rsync

The user needs a real shell, because sshd runs the forced command below through it. useradd gives it no password, so its account is locked to everything but the key. Debian's rsync package brings /usr/bin/rrsync, the script that locks the key down.

Check

server
id deploy; sudo grep '^deploy:' /etc/shadow | cut -d: -f2; ls -ld /var/www/homelab; ls -l /usr/bin/rrsync

A single group of its own, ! (locked), a web root owned by deploy and readable by nginx, and rrsync.

2. A key that can only write#

Only your laptop uses it, so it gets a passphrase, like the admin key. What protects it most, though, is what it is allowed to do.

laptop
ssh-keygen -t ed25519 -f ~/.ssh/homelab-deploy -C "deploy www.azit.se"
scp ~/.ssh/homelab-deploy.pub azit:
server
sudo install -d -o deploy -g deploy -m 700 /home/deploy/.ssh
printf 'command="/usr/bin/rrsync -wo /var/www/homelab",restrict %s\n' "$(cat ~/homelab-deploy.pub)" | sudo tee /home/deploy/.ssh/authorized_keys >/dev/null
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
rm ~/homelab-deploy.pub

Check

laptop
ssh -i ~/.ssh/homelab-deploy -o IdentitiesOnly=yes deploy@www.azit.se

PTY allocation request failed and rrsync error: Not invoked via sshd, then the connection closes: no shell. The first time, ssh asks about the host key under its new name; it should be the fingerprint you checked in chapter 1.

3. The first deploy#

laptop
cat >> ~/.ssh/config <<'EOF'
Host azit-deploy
    HostName www.azit.se
    User deploy
    IdentityFile ~/.ssh/homelab-deploy
    IdentitiesOnly yes
EOF
deploy/deploy.sh site repositoryDownload
#!/bin/sh
# Build and copy the site to the server from your own machine.
#   DEPLOY_TARGET=deploy@web.example.com: ./deploy/deploy.sh
# The target ends in ":" because the server's rrsync already fixes the folder.
set -eu
cd "$(dirname "$0")/.."
: "${DEPLOY_TARGET:?set DEPLOY_TARGET, for example deploy@web.example.com:}"
node build.mjs --strict
rsync -rlz --delete --checksum --itemize-changes public/ "$DEPLOY_TARGET"
  • --strict: nothing is copied while a placeholder or a possible leak is left.
  • The target ends in :, with no folder: rrsync has already fixed it, and paths are relative to it.
  • --delete removes what the build no longer makes, such as an old asset after its content hash changed. --itemize-changes lists every file that changed.
laptop
DEPLOY_TARGET=azit-deploy: sh deploy/deploy.sh

macOS ships openrsync as rsync; rrsync accepts it for these options, so Homebrew's rsync is not needed.

Check

laptop
curl -s -o /dev/null -w '%{http_code}\n' https://www.azit.se/
curl -s https://www.azit.se/.well-known/security.txt
curl -s -o /dev/null -w '%{http_code}\n' https://www.azit.se/nope

200; the contact, an Expires date a year ahead, and the policy; and 404, with the site's own page.

4. Only from the laptop#

The repository has a GitHub workflow that could deploy on every push, but here it only builds and checks. Deploying from GitHub would mean opening port 22 to everyone, since GitHub's runners connect from many changing addresses, and handing a third party a key to the server. From the laptop, port 22 stays open to your own address alone, and the only copy of the deploy key is yours.

.github/workflows/deploy.yml site repositoryDownload
Show the whole file, 54 lines
# Builds and checks the site on every push to main. Deploys run from the laptop (deploy/deploy.sh),
# so port 22 stays open to one address only. To deploy from here instead, set these secrets
# and open port 22 to everyone in the provider's firewall:
#   DEPLOY_KEY          private half of an SSH key made only for this (ssh-keygen -t ed25519)
#   DEPLOY_KNOWN_HOSTS  the server's host key line, checked against the server itself (see deploy/DEPLOY.md)
#   DEPLOY_TARGET       for example deploy@web.example.com:
# Until DEPLOY_TARGET is set, the site is built and checked on every push, and the copy is skipped.
#
# The actions are pinned to full commit SHAs: a tag can be moved, a commit cannot.
# To update one, look up the new release's commit, e.g.
#   git ls-remote https://github.com/actions/checkout.git 'refs/tags/v7*'
name: Deploy
on:
  push:
    branches: [main]
  workflow_dispatch:
permissions:
  contents: read
concurrency: deploy
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      # Secrets cannot be tested in an if: directly, so expose the one that decides
      HAS_TARGET: ${{ secrets.DEPLOY_TARGET != '' }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1    # v7.0.1
        with:
          persist-credentials: false   # the job never pushes, so keep no token on disk
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1  # v7.1.0
        with:
          node-version: 24
      - name: Build (fails on placeholders or possible leaks)
        run: node build.mjs --strict
      - name: Check the puzzles (every generated puzzle has exactly one answer)
        run: node src/iq/test.js 300 | grep -E '^(Generated|FAILS|WARN)'
      - name: No server yet
        if: env.HAS_TARGET != 'true'
        run: echo "::notice::DEPLOY_TARGET is not set; the site was built and checked, not copied."
      - name: Copy to the server
        if: env.HAS_TARGET == 'true'
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          TARGET: ${{ secrets.DEPLOY_TARGET }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/deploy && chmod 600 ~/.ssh/deploy
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          trap 'rm -f ~/.ssh/deploy' EXIT
          rsync -rlz --delete --checksum --itemize-changes \
            -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" \
            public/ "$TARGET"
  • Without the secrets, every push is built with --strict and nothing is copied: a second check, in a clean checkout, that the site builds from what is in Git alone.
  • Both actions are pinned to a full commit: a tag can be moved to other code, a commit cannot.
  • If you ever want deploys from GitHub: the three secrets at the top, port 22 open to everyone, and DEPLOY_KNOWN_HOSTS taken from your own checked known_hosts (ssh-keygen -F www.azit.se), never from a fresh ssh-keyscan.

One thing GitHub would have done for you: security.txt gets a new Expires date, a year ahead, with every build. Deploy at least once a year, even with nothing new, and put a reminder in your calendar about eleven months after your last deploy.

Check

A push shows a green run on GitHub ending in DEPLOY_TARGET is not set; the site was built and checked, not copied.