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.
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 gitCreate 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.
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.pubsudo -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-mysiteWrite 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.
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.shAllow 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.
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 mysitesudo -l -U deployerPoint the service at the current link
Make the application or web server use /srv/mysite/current as its working directory or document root, so switching the link switches the live version. For a systemd service, set WorkingDirectory and the program path through the link. For Nginx serving static files, use root /srv/mysite/current/public with the correct subfolder.
Enable the unit now, but let the first deployment start it after current exists. The service account only reads release files. Keep environment secrets outside the repository and add an EnvironmentFile if your app needs them.
sudo useradd --system --home-dir /srv/mysite --shell /usr/sbin/nologin mysite
sudo tee /etc/systemd/system/mysite.service >/dev/null <<'EOF'
[Unit]
Description=Versioned Node.js website
After=network.target
[Service]
User=mysite
Group=mysite
WorkingDirectory=/srv/mysite/current
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node /srv/mysite/current/server.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/mysite.service
sudo systemctl enable mysiteDeploy 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.
sudo -u deployer /srv/mysite/deploy.sh
ls -l /srv/mysite/
ls /srv/mysite/releases/
sudo systemctl status mysite --no-pager | head -n 5Roll 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.
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 mysiteAutomate 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.
command="/srv/mysite/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... ci-deploy-keyOfficial documentation
These upstream references provide more detail on the commands and workflows in this guide.