For a single server, a full CI/CD platform is often more than you need: all you want is for the server to pull the new code and restart the application when you push to main. In this tutorial you will set that up on Ubuntu 24.04 with webhook, a small HTTP server that runs a command when it receives a valid request, a Bash deploy script with a health check and automatic rollback, and Nginx with HTTPS in front. GitHub signs each request, so only your repository can trigger a deployment.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with
sudoprivileges. - An application already running on the server as a systemd service. The examples assume a service called
myapp.servicethat listens on127.0.0.1:3000and answersGET /healthwith HTTP 200. Adapt the names to your application. - Nginx installed, and UFW allowing SSH.
- A domain name with a DNS A record, such as
hooks.your_domain, pointing toyour_server_ip. - A GitHub repository with admin access to add a deploy key and a webhook.
Step 1 - Creating a deploy user and cloning the repository
Deployments should not run as root. Create a dedicated deploy user without a password; it will own the application code and run the webhook listener:
sudo adduser --disabled-password --gecos "" deploy
Give it an SSH key that GitHub will accept as a read-only deploy key:
sudo -u deploy mkdir -m 700 /home/deploy/.ssh
sudo -u deploy ssh-keygen -t ed25519 -N "" -C "deploy@your_server" -f /home/deploy/.ssh/id_ed25519
sudo cat /home/deploy/.ssh/id_ed25519.pub
In GitHub, open the repository, go to Settings > Deploy keys > Add deploy key, paste the public key and leave Allow write access unchecked.
Add GitHub's host key to the user's known_hosts so git does not stop to ask for confirmation, then compare the fingerprints printed by ssh-keygen -lf with the ones GitHub publishes in its documentation:
ssh-keyscan -t ed25519 github.com | sudo -u deploy tee -a /home/deploy/.ssh/known_hosts
sudo -u deploy ssh-keygen -lf /home/deploy/.ssh/known_hosts
Clone the repository into /srv/myapp, owned by deploy. If the code already lives there, change its owner with sudo chown -R deploy:deploy /srv/myapp instead and make sure the origin remote uses the SSH URL:
sudo install -d -o deploy -g deploy /srv/myapp
sudo -u deploy git clone [email protected]:your_org/your_repo.git /srv/myapp
Check that the user can fetch without a prompt:
sudo -u deploy git -C /srv/myapp fetch origin
The command should finish silently. If you see Permission denied (publickey), the deploy key is missing or was added to another repository.
Step 2 - Allowing the deploy user to restart the service
The deploy script must restart myapp.service, which requires root. Rather than giving deploy full sudo, allow exactly that one command. Create a sudoers drop-in with visudo, which checks the syntax before saving:
sudo visudo -f /etc/sudoers.d/deploy-myapp
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service
Verify the rule. -n makes sudo fail instead of prompting, which is how the script will call it:
sudo -u deploy sudo -n /usr/bin/systemctl restart myapp.service && echo OK
OK
Step 3 - Writing the deploy script
The script does four things: waits if another deployment is already running, updates the code to the latest commit on main, restarts the service and checks the health endpoint. If the health check fails, it goes back to the previous commit.
Create the script:
sudo nano /usr/local/bin/deploy-myapp
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/srv/myapp
BRANCH=main
SERVICE=myapp.service
HEALTH_URL=http://127.0.0.1:3000/health
# Send all output to the journal: journalctl -t deploy-myapp
exec > >(logger -t deploy-myapp) 2>&1
# One deployment at a time; a second push waits for the first to finish
exec 9>/run/lock/deploy-myapp.lock
flock 9
build() {
# Replace with your build steps, for example:
# npm ci --omit=dev
# .venv/bin/pip install -r requirements.txt
:
}
healthy() {
for _ in $(seq 1 10); do
if curl -fsS -o /dev/null "$HEALTH_URL"; then
return 0
fi
sleep 3
done
return 1
}
cd "$APP_DIR"
previous=$(git rev-parse HEAD)
git fetch --quiet origin "$BRANCH"
git reset --hard "origin/$BRANCH"
current=$(git rev-parse HEAD)
if [[ "$previous" == "$current" ]]; then
echo "Already at ${current:0:7}, nothing to deploy"
exit 0
fi
echo "Deploying ${previous:0:7} -> ${current:0:7}"
build
sudo -n /usr/bin/systemctl restart "$SERVICE"
if healthy; then
echo "Deployment of ${current:0:7} succeeded"
exit 0
fi
echo "Health check failed, rolling back to ${previous:0:7}"
git reset --hard "$previous"
build
sudo -n /usr/bin/systemctl restart "$SERVICE"
healthy && echo "Rollback to ${previous:0:7} succeeded" || echo "Rollback failed, manual action needed"
exit 1
git reset --hard discards any local changes in /srv/myapp, which is what you want on a server whose code comes only from Git. Keep configuration and uploaded files outside the repository (for example in /etc/myapp or /var/lib/myapp).
Make the script executable and run it once by hand as deploy:
sudo chmod 755 /usr/local/bin/deploy-myapp
sudo -u deploy /usr/local/bin/deploy-myapp
The output goes to the journal. Read it:
sudo journalctl -t deploy-myapp -n 5
Sep 25 10:12:03 server deploy-myapp[4121]: Already at 3f9c2ab, nothing to deploy
Step 4 - Installing and configuring webhook
webhook is packaged in Ubuntu. Install it:
sudo apt update
sudo apt install webhook
Generate a random secret. GitHub will use it to sign each request, and webhook will reject anything with a wrong signature:
openssl rand -hex 32
Create the hooks file:
sudo mkdir -p /etc/webhook
sudo nano /etc/webhook/hooks.json
Paste the following and replace your_webhook_secret with the value you just generated:
[
{
"id": "deploy-myapp",
"execute-command": "/usr/local/bin/deploy-myapp",
"command-working-directory": "/srv/myapp",
"http-methods": ["POST"],
"response-message": "Deployment started",
"trigger-rule-mismatch-http-response-code": 403,
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hmac-sha256",
"secret": "your_webhook_secret",
"parameter": { "source": "header", "name": "X-Hub-Signature-256" }
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": { "source": "payload", "name": "ref" }
}
}
]
}
}
]
The hook runs only when both rules match: the body is signed with your secret, and the push is to main. Pushes to other branches get a 403 and do nothing.
The file contains the secret, so make it readable only by root and the deploy group:
sudo chown root:deploy /etc/webhook/hooks.json
sudo chmod 640 /etc/webhook/hooks.json
Step 5 - Running webhook as a systemd service
Create a unit that runs the listener as deploy and binds it to localhost only, since Nginx will be the public entry point. The Ubuntu package may ship its own webhook.service; a unit with the same name in /etc/systemd/system takes precedence over it:
sudo nano /etc/systemd/system/webhook.service
[Unit]
Description=Webhook listener for deployments
After=network-online.target
Wants=network-online.target
[Service]
User=deploy
Group=deploy
ExecStart=/usr/bin/webhook -hooks /etc/webhook/hooks.json -ip 127.0.0.1 -port 9000 -verbose
Restart=on-failure
[Install]
WantedBy=multi-user.target
Load and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
sudo systemctl status webhook --no-pager
Send an unsigned request. It must be refused:
curl -i -X POST http://127.0.0.1:9000/hooks/deploy-myapp
HTTP/1.1 403 Forbidden
...
Hook rules were not satisfied.
Now send a correctly signed request, the same way GitHub does:
payload='{"ref":"refs/heads/main"}'
sig=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "your_webhook_secret" | awk '{print $2}')
curl -X POST -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$sig" \
-d "$payload" http://127.0.0.1:9000/hooks/deploy-myapp
Deployment started
webhook answers immediately and runs the script in the background, which matters because GitHub cancels deliveries that take longer than 10 seconds. journalctl -t deploy-myapp shows the run.
Step 6 - Publishing the endpoint with Nginx and HTTPS
Create a server block that forwards only /hooks/ to the listener:
sudo nano /etc/nginx/sites-available/hooks
server {
listen 80;
server_name hooks.your_domain;
location /hooks/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
return 404;
}
}
Enable it, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/hooks /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Open HTTP and HTTPS in the firewall and get a Let's Encrypt certificate. Certbot edits the server block to add TLS and a redirect from HTTP:
sudo ufw allow 'Nginx Full'
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d hooks.your_domain
Check from your own computer that the public endpoint also rejects unsigned requests:
curl -i -X POST https://hooks.your_domain/hooks/deploy-myapp
HTTP/1.1 403 Forbidden
Step 7 - Adding the webhook in GitHub
In the repository, go to Settings > Webhooks > Add webhook and fill in:
- Payload URL:
https://hooks.your_domain/hooks/deploy-myapp - Content type:
application/json. With the default form encoding,webhookcannot read thereffield and every delivery is refused. - Secret: the same value as
your_webhook_secret. - Which events: Just the push event.
When you save, GitHub sends a ping event. It has no ref field, so it gets a 403: that is expected and does not affect push deliveries.
Push a commit to main and follow the deployment on the server:
sudo journalctl -t deploy-myapp -f
Sep 25 10:20:41 server deploy-myapp[4388]: Deploying 3f9c2ab -> 8d41e0c
Sep 25 10:20:47 server deploy-myapp[4388]: Deployment of 8d41e0c succeeded
In GitHub, Recent Deliveries on the webhook page should show the push with a 200 response.
Using GitLab instead of GitHub
GitLab does not sign the body; it sends the secret token as-is in the X-Gitlab-Token header. Replace the first rule in hooks.json with a plain value match, then restart the service with sudo systemctl restart webhook:
{
"match": {
"type": "value",
"value": "your_webhook_secret",
"parameter": { "source": "header", "name": "X-Gitlab-Token" }
}
}
In GitLab, add the webhook under Settings > Webhooks with the same URL, the secret in Secret token and the Push events trigger.
Troubleshooting
- Every GitHub delivery gets a 403: the content type is not
application/json, the secret differs between GitHub andhooks.json, or the push was not tomain. Runsudo journalctl -u webhook -n 50to see which rule failed. - The hook fires but nothing is deployed: check
sudo journalctl -t deploy-myapp.Host key verification failedmeansknown_hostsis missing fordeploy;sudo: a password is requiredmeans the command in the script does not match the sudoers line exactly. fork/exec /usr/local/bin/deploy-myapp: permission denied: the script is not executable. Runsudo chmod 755 /usr/local/bin/deploy-myapp.- Changes to
hooks.jsonare ignored:webhookreads the file at startup. Restart it withsudo systemctl restart webhook.
Conclusion
Every push to main now triggers a signed request that updates the code, restarts the application and rolls back automatically if the health check fails, without exposing anything but a single HTTPS endpoint. From here you can run your test suite in GitHub Actions and only push to main when it passes, add a notification to the script when a rollback happens, or move to container images and a blue-green setup with Nginx for zero-downtime releases.