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#
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 rsyncThe 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
id deploy; sudo grep '^deploy:' /etc/shadow | cut -d: -f2; ls -ld /var/www/homelab; ls -l /usr/bin/rrsyncA 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.
ssh-keygen -t ed25519 -f ~/.ssh/homelab-deploy -C "deploy www.azit.se"
scp ~/.ssh/homelab-deploy.pub azit: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.pubcommand=: whatever the client asks for, sshd runs onlyrrsync, which accepts nothing but an rsync transfer, with every path inside/var/www/homelab.-wo: write only. A deploy only ever sends, so the key cannot use rsync to read anything back.restrict: no port, agent or X11 forwarding, no terminal, and whatever later OpenSSH versions add to that list.
Check
ssh -i ~/.ssh/homelab-deploy -o IdentitiesOnly=yes deploy@www.azit.sePTY 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#
cat >> ~/.ssh/config <<'EOF'
Host azit-deploy
HostName www.azit.se
User deploy
IdentityFile ~/.ssh/homelab-deploy
IdentitiesOnly yes
EOF#!/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:rrsynchas already fixed it, and paths are relative to it. --deleteremoves what the build no longer makes, such as an old asset after its content hash changed.--itemize-changeslists every file that changed.
DEPLOY_TARGET=azit-deploy: sh deploy/deploy.shmacOS ships openrsync as rsync; rrsync accepts it for these options, so Homebrew's rsync is not needed.
Check
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/nope200; 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.
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
--strictand 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_HOSTStaken from your own checkedknown_hosts(ssh-keygen -F www.azit.se), never from a freshssh-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.