--- title: "How to Auto-Deploy from GitHub with Webhook on Ubuntu 24.04" description: "Deploy automatically on every Git push using the webhook tool, a safe Bash deploy script with health check and rollback, Nginx with HTTPS and signed requests." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/continuous-deployment-with-webhook-and-scripts updated: 2026-09-25 --- # How to Auto-Deploy from GitHub with Webhook on Ubuntu 24.04 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: ```bash sudo adduser --disabled-password --gecos "" deploy ``` Give it an SSH key that GitHub will accept as a read-only *deploy key*: ```bash 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: ```bash 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: ```bash sudo install -d -o deploy -g deploy /srv/myapp sudo -u deploy git clone git@github.com:your_org/your_repo.git /srv/myapp ``` Check that the user can fetch without a prompt: ```bash 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: ```bash sudo visudo -f /etc/sudoers.d/deploy-myapp ``` ```text 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: ```bash sudo -u deploy sudo -n /usr/bin/systemctl restart myapp.service && echo OK ``` ```output 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: ```bash sudo nano /usr/local/bin/deploy-myapp ``` ```bash #!/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`: ```bash sudo chmod 755 /usr/local/bin/deploy-myapp sudo -u deploy /usr/local/bin/deploy-myapp ``` The output goes to the journal. Read it: ```bash sudo journalctl -t deploy-myapp -n 5 ``` ```output 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: ```bash 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: ```bash openssl rand -hex 32 ``` Create the hooks file: ```bash 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: ```json [ { "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: ```bash 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: ```bash sudo nano /etc/systemd/system/webhook.service ``` ```ini [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: ```bash sudo systemctl daemon-reload sudo systemctl enable --now webhook sudo systemctl status webhook --no-pager ``` Send an unsigned request. It must be refused: ```bash curl -i -X POST http://127.0.0.1:9000/hooks/deploy-myapp ``` ```output HTTP/1.1 403 Forbidden ... Hook rules were not satisfied. ``` Now send a correctly signed request, the same way GitHub does: ```bash 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 ``` ```output 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: ```bash sudo nano /etc/nginx/sites-available/hooks ``` ```nginx 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: ```bash 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: ```bash 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: ```bash curl -i -X POST https://hooks.your_domain/hooks/deploy-myapp ``` ```output 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: ```bash sudo journalctl -t deploy-myapp -f ``` ```output 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`: ```json { "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.