Web hostingWritten by GLWS16 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

The workflow

This guide sets up a simple, safe deployment. A script clones your repository into a new, dated release folder, builds it, switches a symlink called current to the new release in one atomic step, restarts the service and checks that the site responds. If the check fails, it switches back. Old releases stay for a short time so you can roll back by pointing the link at one of them.

It works for a static site or an application run by systemd, such as the Node.js service guide describes. Replace the repository address, paths and commands with yours.

For the executable Node.js example below, first install Node.js 24 LTS using the systemd application guide. The repository must contain server.js, package.json and package-lock.json, and its server must listen on process.env.PORT at 127.0.0.1. Static websites require different build and health checks; do not run the Node commands unchanged for them.

Create a deploy user and folders

Run deployments as a dedicated user, not as root. It owns the release folders, while the web server or application only reads them. Give the user no shell access beyond what it needs, and no sudo rights except one command, set up later.

Both systems
sudo useradd --create-home --shell /bin/bash deployer
sudo mkdir -p /srv/mysite/releases
sudo chown -R deployer:deployer /srv/mysite
sudo apt install git 2>/dev/null || sudo dnf install git

Create a read-only deploy key

A deploy key is an SSH key that grants access to one repository and can be read-only. It is safer than your personal key, because if the server is compromised, the attacker can read that one repository, but cannot push changes. Generate it as the deployer user, add the public part in your Git host's repository settings with write access off, and test the connection.

Both systems
sudo install -d -o deployer -g deployer -m 700 /home/deployer/.ssh
sudo -u deployer ssh-keygen -t ed25519 -f /home/deployer/.ssh/deploy_key -N "" -C "deploy key for mysite"
sudo -u deployer cat /home/deployer/.ssh/deploy_key.pub
Tell SSH to use it for your Git host
sudo -u deployer tee /home/deployer/.ssh/config >/dev/null <<'EOF'
Host git-mysite
    HostName github.com
    User git
    IdentityFile ~/.ssh/deploy_key
    IdentitiesOnly yes
EOF
sudo -u deployer chmod 600 /home/deployer/.ssh/config
sudo -u deployer ssh -T git-mysite
Replace github.com with your Git host. The test command prints a greeting and exits with an error status for most hosts, which is normal.

Write the deployment script

The script creates the new release, runs the build, switches the link and verifies the result. The switch uses a temporary link and mv -T, which replaces the old link in one atomic operation, so visitors never see a half-deployed site. The script keeps the five newest releases and removes the rest.

A failed service restart also triggers rollback. The first failed deployment stops the service rather than leaving a broken release active. Cleanup preserves the current and previous targets even if a manual rollback points at an older directory.

Both systems: create /srv/mysite/deploy.sh as the deployer user
sudo -u deployer tee /srv/mysite/deploy.sh >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail
BASE=/srv/mysite
REPO=git-mysite:youraccount/yourrepo.git
BRANCH=main
URL=http://127.0.0.1:3000/
NEW="$BASE/releases/$(date +%Y%m%d%H%M%S%N)"
PREV=$(readlink -f "$BASE/current" || true)

git clone --depth 1 --branch "$BRANCH" "$REPO" "$NEW"
cd "$NEW"
npm ci --omit=dev
# For apps with a build step, include development dependencies while building.
# npm run build
ln -sfn "$NEW" "$BASE/current.tmp"
mv -T "$BASE/current.tmp" "$BASE/current"

if sudo systemctl restart mysite &&
   curl -fsS --max-time 10 --retry 10 --retry-delay 1 --retry-all-errors "$URL" >/dev/null; then
  echo "Health check passed"
else
  echo "Health check failed" >&2
  if [ -n "$PREV" ] && [ -d "$PREV" ]; then
    ln -sfn "$PREV" "$BASE/current.tmp"
    mv -T "$BASE/current.tmp" "$BASE/current"
    sudo systemctl restart mysite
    echo "Restored previous release: $PREV" >&2
  else
    sudo systemctl stop mysite
    rm -f "$BASE/current"
    echo "No previous release: stopped the failed first deployment" >&2
  fi
  exit 1
fi

CURRENT=$(readlink -f "$BASE/current")
count=0
while IFS= read -r name; do
  count=$((count + 1))
  candidate="$BASE/releases/$name"
  [ "$count" -gt 5 ] || continue
  [ "$candidate" != "$CURRENT" ] && [ "$candidate" != "$PREV" ] || continue
  rm -rf -- "$candidate"
done < <(find "$BASE/releases" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r)
echo "Deployed $NEW"
EOF
sudo chmod 755 /srv/mysite/deploy.sh

Allow only this service’s restart and stop commands

Allow the deployer to restart this one service, and to stop it when its first deployment has no working release to roll back to. Do not give permission to change units or restart other services. Create the rule with visudo and validate it before deployment.

Both systems
sudo visudo -f /etc/sudoers.d/deployer-mysite
# add this single line:
# deployer ALL=(root) NOPASSWD: /usr/bin/systemctl restart mysite, /usr/bin/systemctl stop mysite
Check
sudo -l -U deployer

Deploy and check

Run the script as the deployer user. Watch the output, and confirm the site and the service. Run it a second time to check the second release flow works and that the link moves.

Both systems
sudo -u deployer /srv/mysite/deploy.sh
ls -l /srv/mysite/
ls /srv/mysite/releases/
sudo systemctl status mysite --no-pager | head -n 5

Roll back by hand

If a problem appears after a successful deployment, point the link at an older release and restart. This takes seconds. Remember that code rollback does not undo database changes, so make migrations backwards compatible, or take a backup before running them.

Both systems
ls -1t /srv/mysite/releases/
sudo -u deployer ln -sfn /srv/mysite/releases/PREVIOUS_RELEASE /srv/mysite/current.tmp
sudo -u deployer mv -T /srv/mysite/current.tmp /srv/mysite/current
sudo systemctl restart mysite

Automate it, carefully

You can run the script from a CI job over SSH after tests pass. Give that job its own SSH key restricted to this one command, with the command option in authorized_keys, rather than a general login. Keep secrets such as database passwords in an environment file outside the repository, not in the code.

Both systems: example authorized_keys line for the deployer user (one line)
command="/srv/mysite/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... ci-deploy-key

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.