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 sudo privileges.
  • An application already running on the server as a systemd service. The examples assume a service called myapp.service that listens on 127.0.0.1:3000 and answers GET /health with 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 to your_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, webhook cannot read the ref field 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 and hooks.json, or the push was not to main. Run sudo journalctl -u webhook -n 50 to see which rule failed.
  • The hook fires but nothing is deployed: check sudo journalctl -t deploy-myapp. Host key verification failed means known_hosts is missing for deploy; sudo: a password is required means 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. Run sudo chmod 755 /usr/local/bin/deploy-myapp.
  • Changes to hooks.json are ignored: webhook reads the file at startup. Restart it with sudo 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.