Server webhooks with nginx and systemd
Webhooks let one service notify another when something happens, for example, triggering a deploy right after you push code to your Git repository. Many tools (and SaaS platforms) exist to receive and process webhooks, such as adnanh/webhook. But if you already run nginx, you can build a lightweight, reliable webhook receiver without adding another daemon.
In this post, we’ll use nginx and systemd to:
- Expose a secret, exact-match URL that returns 204 on success.
- Capture the
version
query parameter into a per-environment log. - Have systemd react to that log change and run a oneshot deploy script.
I’ll also show a GitHub Actions example for triggering the endpoint, plus a few notes on log rotation and security.
Below is an example with staging
and release
environments.
nginx: minimal webhook endpoints
- One access log for audit.
- One per-environment access log that contains only the
version
query string. - Exact-match locations with a long, unguessable secret segment (you can use, for example,
openssl rand -hex 24
). - 204 response on success, 404 otherwise.
Note: Make sure the directory for logs exists and nginx can write to it.
# /etc/nginx/conf.d/webhook.conf
log_format request '$request $status';
log_format version $arg_version;
server {
server_name _;
listen [::]:443 ssl http2;
listen 443 ssl http2;
root /var/www/html;
access_log /var/log/nginx/webhook/webhook.log request;
location = /2ff16954429425ead56aaaacc01ec8c158fe57f04ce75a49/deploy-app/staging {
access_log /var/log/nginx/webhook/app/staging version;
return 204;
}
location = /edbf95c935a1fb9f0566ec15f618a8b4b9d94e7fe0e4b831/deploy-app/prod {
access_log /var/log/nginx/webhook/app/prod version;
return 204;
}
location / {
return 404;
}
Create log directories and reload nginx:
sudo mkdir -p /var/log/nginx/webhook/app
sudo nginx -t && sudo systemctl reload nginx
systemd service: do the work
- Use tail -n1 so if multiple hits land before your service runs, you deploy the most recent version.
- Truncate the per-environment log after a successful run to make the next trigger clean.
- Keep it oneshot and small. Consider adding sandboxing options later.
# /lib/systemd/system/deploy-app@.service
[Unit]
Description=Deploy app (%i) triggered by nginx webhook
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# Ensure there's something in the per-env log before proceeding
ExecStartPre=/usr/bin/test -s /var/log/nginx/webhook/app/%i
# Capture the last requested version into a place your deploy script reads
ExecStart=/usr/bin/sh -c 'tail -n1 /var/log/nginx/webhook/app/%i > /etc/app/%i/version';
# Run your deploy; receives 'staging' or 'release' as $1
ExecStart=/usr/local/bin/deploy-app %i
# Clear the trigger log for the next event
ExecStopPost=/usr/bin/truncate -s 0 /var/log/nginx/webhook/app/%i
# Recommended hardening (tune to your environment)
#User=app
#Group=app
#WorkingDirectory=/etc/app/%i
#ProtectSystem=strict
#ProtectHome=true
#PrivateTmp=true
#NoNewPrivileges=true
systemd path: trigger on log change
- The path unit watches the per-environment log for modifications.
- Instantiate one path unit per environment.
# /lib/systemd/system/deploy-app@.path
[Unit]
Description=Watch webhook log and trigger deploy for %i
[Path]
PathModified=/var/log/nginx/webhook/app/%i
Unit=deploy-app@%i.service
[Install]
WantedBy=multi-user.target
Enable and start the watchers:
sudo systemctl daemon-reload
sudo systemctl enable --now deploy-app@staging.path
sudo systemctl enable --now deploy-app@release.path
GitHub Actions workflow
- Keep the endpoint names consistent with nginx: staging and release.
- The version is either a branch name or a tag value.
- Alternative github action you may consider: fjogeleit/http-request-action.
# .github/workflows/deploy.yml
name: Trigger deploy
on:
push:
branches:
- main
tags:
- 'v*'
deploy:
name: Trigger deploy
runs-on: ubuntu-latest
steps:
- name: Deploy staging
if: github.ref == 'refs/heads/main'
uses: satak/webrequest-action@v1.2.4
with:
url: "https://webhook.app.io/${{ secrets.WEBHOOK_SECRET }}/deploy-app/staging?version=main"
method: GET
- name: Get release version
if: startsWith(github.ref, 'refs/tags/v')
id: version
shell: bash
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
env:
GITHUB_REF: ${{ github.ref }}
- name: Deploy release
if: startsWith(github.ref, 'refs/tags/v')
uses: satak/webrequest-action@v1.2.4
with:
url: "https://webhook.app.io/${{ secrets.WEBHOOK_SECRET }}/deploy-app/release?version=${{ steps.version.outputs.version }}"
method: GET
deploy script example
Example skeleton expected by the service above.
#!/usr/bin/env bash
set -euo pipefail
ENVIRONMENT="${1:?usage: deploy-app <staging|release>}"
VERSION_FILE="/etc/app/${ENVIRONMENT}/version"
if [[ ! -s "$VERSION_FILE" ]]; then
echo "No version provided" >&2
exit 1
fi
VERSION="$(tail -n1 "$VERSION_FILE")"
echo "Deploying ${ENVIRONMENT} version: ${VERSION}"
# Example:
# git -C /srv/app fetch origin
# git -C /srv/app checkout --force "refs/heads/${VERSION}" # or refs/tags/${VERSION}
# docker compose -f /srv/app/compose.${ENVIRONMENT}.yml pull
# docker compose -f /srv/app/compose.${ENVIRONMENT}.yml up -d --remove-orphans
echo "Done."
Testing
- Staging test
curl -i "https://webhook.app.io/SECRET/deploy-app/staging?version=test-123"
- Check:
systemctl status deploy-app@staging
and/etc/app/staging/version
- Release test
curl -i "https://webhook.app.io/SECRET/deploy-app/release?version=1.2.3"
- Check:
systemctl status deploy-app@release
and/etc/app/release/version
- Endpoints should return HTTP 204.
Log rotation
The per-environment logs are truncated after each run, but the audit log grows.
# /etc/logrotate.d/nginx-webhook
/var/log/nginx/webhook/webhook.log {
rotate 12
weekly
missingok
compress
delaycompress
notifempty
create 0640 www-data adm
postrotate
[ -s /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}
Security notes
- Keep the secret path long and random; rotate if exposed.
- Serve only over TLS.
- Optionally:
- Add basic IP rate limiting on the server block.
- Restrict to GET as shown (or switch to POST if you prefer).
- If you need HMAC validation or more complex checks, consider adding an njs/lua validation step or move to a small webhook verifier service.
That’s it: nginx writes the version to a tiny file, systemd notices and runs your deploy. No extra daemon needed.