webhook 2.8.3 on AWS User Guide
Overview
This guide covers launching and using the webhook 2.8.3 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened Apache Subversion (SVN) & CI/CD Tools listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
webhook is a small open source server that turns HTTP requests into commands. You define hooks in a JSON file: each hook has an address (/hooks/<id>), a rule the request must match (for example a secret header) and the command to run when it does. It is a common way to let a Git host, a CI system or a monitoring tool trigger a deployment or a script on a server.
The cloudimg image adds:
- A current Go build. webhook 2.8.3 is rebuilt by cloudimg from the unmodified upstream source with Go 1.27.3 (the upstream release binary is built with an unsupported Go release that has known security fixes missing), and runs with Go's request-parsing limits switched on: at most 10000 query or form parameters and 3000 cookies per request.
- HTTPS from first boot. nginx serves webhook on port 443 with a certificate generated on your instance; port 80 only redirects. webhook itself listens on
127.0.0.1:9000only. - A trigger token unique to your instance. The image ships only a placeholder, and webhook will not start until first boot has replaced it. First boot generates a 64 character token and writes it to a file only root can read.
- One safe demo hook.
/hooks/demoacceptsPOSTonly and runs a fixed script that takes no input from the request. A request without the token, with a wrong one, or with the token anywhere but theX-Webhook-Tokenheader gets403and runs nothing. - Hook commands kept in a sandbox. Every hook command runs as the unprivileged
webhookuser inside webhook's systemd sandbox: no route to the EC2 instance metadata service, nosudo, no capabilities, a read-only system outside/var/lib/webhook, and no way to change webhook's own configuration or read the credentials file, the TLS key or the SSH host keys. - A rate limit and quiet logs. nginx limits hook requests per client address, refuses anything but
/hooks/before it reaches webhook, and never writes a query string to its access log. - Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
What is on the image: webhook 2.8.3 (MIT, rebuilt from the upstream source), nginx from Ubuntu 24.04, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet.
- A security group that allows TCP 443 (the hooks over HTTPS) and TCP 80 (redirects to HTTPS) from the systems that will call your hooks, and TCP 22 (SSH) from your own network only. webhook needs no other inbound port.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick webhook 2.8.3 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (webhook itself needs very little; a smaller type is fine if your hook commands are light), your key pair, a security group as above, and a subnet. Under Advanced details, leave IAM instance profile empty (or choose a least-privilege one), set Metadata version to V2 only and Metadata response hop limit to 1.
With the AWS CLI (replace the values in angle brackets; this launches with no instance profile):
aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
--instance-type m5.large --key-name <your-key-pair> \
--security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled,HttpPutResponseHopLimit=1 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=webhook}]'
Step 2: Connect to your instance
| Version | SSH user |
|---|---|
| webhook 2.8.3 on Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
First boot works out the instance's name, generates the certificate and the trigger token, starts webhook, proves over HTTPS that the demo hook refuses requests without the token and runs with it, and only then opens the web server. It takes under a minute:
systemctl is-active webhook nginx webhook-set-host
active
active
active
webhook-set-host runs on every boot before nginx: it keeps the served name, the certificate and nginx's host rules in step (Step 8).
Only SSH (22) and nginx (80, 443) listen on the network. webhook (9000) is on the loopback address only. The :53 entries are Ubuntu's local DNS resolver, :323 is the time service on the loopback address and :68 is the DHCP client:
sudo ss -lntup | awk 'NR>1 {print $1, $5}' | sort -u
tcp 0.0.0.0:22
tcp 0.0.0.0:443
tcp 0.0.0.0:80
tcp 127.0.0.1:9000
tcp 127.0.0.53%lo:53
tcp 127.0.0.54:53
tcp [::]:22
tcp [::]:443
tcp [::]:80
udp 127.0.0.1:323
udp 127.0.0.53%lo:53
udp 127.0.0.54:53
udp 172.31.81.62%ens5:68
udp [::1]:323
webhook runs as the webhook account with the hooks file /etc/webhook/hooks.json, on the loopback address, accepting POST only by default:
ps -o user:10=,args= -C webhook
/usr/local/bin/webhook -version
webhook /usr/local/bin/webhook -hooks /etc/webhook/hooks.json -ip 127.0.0.1 -port 9000 -verbose -http-methods POST
webhook version 2.8.3
If nginx is not running, first boot has not finished, or it failed, in which case the web server is never opened. Check systemctl status webhook-firstboot and sudo tail -40 /var/log/cloudimg/webhook-firstboot.log.
Step 4: Get your hook address and token
sudo cat /etc/cloudimg-credentials.txt
The file is readable by root only and holds the host name, the demo hook's address, its trigger token and the certificate fingerprint:
# webhook 2.8.3 - generated on first boot. Unique to this instance; keep it safe.
# Trigger the demo hook (POST) with the header X-Webhook-Token: <WEBHOOK_TOKEN> at WEBHOOK_URL. Compare
# WEBHOOK_TLS_SHA256 with the certificate the server presents (or install your own certificate: see the guide).
# Anyone who holds a hook's secret can run that hook's command as the webhook user.
# New secret for the demo hook: sudo webhook-set-token
WEBHOOK_HOST=ec2-203-0-113-10.compute-1.amazonaws.com
WEBHOOK_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com/hooks/demo
WEBHOOK_TOKEN=<WEBHOOK_TOKEN>
WEBHOOK_TLS_SHA256=15:1A:95:B3:94:79:1A:2D:4C:0E:A8:91:DC:F4:BC:AF:C8:80:66:6B:22:BD:AA:50:89:ED:C0:2C:F3:0E:BB:1B
To see everything but the token:
sudo grep -E '^WEBHOOK_(HOST|URL|TLS_SHA256)=' /etc/cloudimg-credentials.txt
WEBHOOK_HOST=ec2-203-0-113-10.compute-1.amazonaws.com
WEBHOOK_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com/hooks/demo
WEBHOOK_TLS_SHA256=15:1A:95:B3:94:79:1A:2D:4C:0E:A8:91:DC:F4:BC:AF:C8:80:66:6B:22:BD:AA:50:89:ED:C0:2C:F3:0E:BB:1B
You can confirm the whole path from the instance itself. The helper goes through nginx over HTTPS with the certificate pinned. It checks that the token runs the demo command, and that no token, a wrong token, the placeholder, an empty header, an upper-cased token, a duplicated header, the token in the request body and a request from another site are all refused without running anything:
sudo /usr/local/sbin/webhook-verify.sh
WEBHOOK_ROUNDTRIP_OK (HTTPS pinned: the secret ran the demo command (marker advanced, output returned); no token, wrong, placeholder, empty, upper-cased, duplicate-header, form/JSON-body secrets and a cross-origin POST all 403 and ran nothing; GET 405; only /hooks/ proxied)
Change the token with webhook-set-token. With no argument it generates a new 64 character token; with - it reads yours from standard input (32 to 128 characters of A-Z a-z 0-9 . _ ~ -). It never accepts the token as an argument, so it does not appear in the process list or your shell history. webhook restarts to apply it, and the command updates /etc/cloudimg-credentials.txt:
$ sudo webhook-set-token
webhook-set-token: the demo hook's secret was replaced (sudo cat /etc/cloudimg-credentials.txt)
To choose your own:
$ read -rs NEWTOKEN && printf '%s\n' "$NEWTOKEN" | sudo webhook-set-token - ; unset NEWTOKEN
webhook-set-token: the demo hook's secret was replaced (sudo cat /etc/cloudimg-credentials.txt)
webhook-set-token rewrites the whole hooks file, so it works only while the demo hook is the only hook; once you have added your own (Step 6) it refuses, and you change tokens by editing the file.
Step 5: Call the demo hook
The demo hook runs /usr/local/bin/webhook-demo.sh, which writes the time to /var/lib/webhook/last-demo-run and prints one line. On the instance itself, this reads the token into a header file (it is never printed or put on a command line), calls the hook with and without it, and shows the time the command last ran (read as the webhook user, since a hook command can write anything in its own folder). curl checks the certificate against the instance's own copy with --cacert:
D=$(mktemp -d) && cd "$D" && umask 077
HOST=$(sudo sed -n 's/^WEBHOOK_HOST=//p' /etc/cloudimg-credentials.txt)
sudo sed -n 's/^WEBHOOK_TOKEN=/X-Webhook-Token: /p' /etc/cloudimg-credentials.txt > token.hdr
C=(-s --max-time 30 --cacert /etc/ssl/webhook/webhook.crt --resolve "${HOST}:443:127.0.0.1")
curl "${C[@]}" -X POST -H @token.hdr -w '\nwith the token: %{http_code}\n' "https://${HOST}/hooks/demo"
curl "${C[@]}" -X POST -w '\nwithout a token: %{http_code}\n' "https://${HOST}/hooks/demo"
curl "${C[@]}" -X POST -H 'X-Webhook-Token: wrong-token-0123456789abcdef0123456789' -w '\nwrong token: %{http_code}\n' "https://${HOST}/hooks/demo"
sudo -u webhook cat /var/lib/webhook/last-demo-run
cd / && rm -rf "$D"
cloudimg webhook demo hook executed successfully at 2026-11-08T10:12:31.204715032Z
with the token: 200
Hook rules were not satisfied.
without a token: 403
Hook rules were not satisfied.
wrong token: 403
2026-11-08T10:12:31.204715032Z
From your own computer, first copy the instance's certificate so curl can check it (compare its fingerprint with WEBHOOK_TLS_SHA256), then call the hook with the token from Step 4. Keep the token out of your shell history: put the header in a file only you can read, as above:
ssh -i <your-key.pem> ubuntu@<public-ip> cat /etc/ssl/webhook/webhook.crt > webhook.crt
openssl x509 -in webhook.crt -noout -fingerprint -sha256
(umask 077; printf 'X-Webhook-Token: %s\n' '<WEBHOOK_TOKEN>' > token.hdr)
curl --cacert webhook.crt -X POST -H @token.hdr https://<WEBHOOK_HOST>/hooks/demo
cloudimg webhook demo hook executed successfully at 2026-11-08T10:14:02.551873119Z
Do not use curl -k (--insecure): it sends the token to whichever server answers. If the calling system cannot be given the certificate, install a certificate it already trusts (Step 8).
Step 6: Add your own hooks
Hooks live in /etc/webhook/hooks.json, a JSON list of hook definitions (upstream documentation). The file is owned by root, readable by the webhook group and writable by root only, and the commands it runs must also be owned by root, so a hook can never change what it or another hook runs. webhook reads the file at start; restart it after a change.
What a token gives. Anyone who holds a hook's token can run that hook's command on this instance, as the webhook user, as often as the rate limit allows. Treat each token like a password, give each caller its own hook and token where you can, and keep these rules:
- Keep commands fixed. Point
execute-commandat a root-owned script that does one thing and takes no input from the request. Avoidpass-arguments-to-command,pass-environment-to-commandandpass-file-to-command; if you must pass request data, have the script validate every value against a strict pattern before using it, and never hand one to a shell,evalor a command line built from strings. - Keep the secret check at the top. Put the token
matchas the hook's wholetrigger-rule, or inside anandwith your other conditions. Never put it undernotor inside anor: webhook 2.8.3 evaluates a missing parameter undernotandorin a way that can let a request without the token through. - Use a header and a long random token. Query strings end up in proxies' and clients' logs; a header does not. Generate tokens with
openssl rand -hex 32. For a Git host that signs its requests (GitHub, Gitea, GitLab), you can use webhook'spayload-hmac-sha256rule with the shared secret instead. - POST only, and only the hooks you use. Remove the demo hook once you have your own.
An example deploy hook, with its own token and a fixed script:
[
{
"id": "deploy-site",
"execute-command": "/usr/local/bin/deploy-site.sh",
"command-working-directory": "/var/lib/webhook",
"http-methods": ["POST"],
"include-command-output-in-response": false,
"trigger-rule-mismatch-http-response-code": 403,
"trigger-rule": {
"match": {
"type": "value",
"value": "<a new token from openssl rand -hex 32>",
"parameter": { "source": "header", "name": "X-Webhook-Token" }
}
}
}
]
sudo install -m 0755 -o root -g root deploy-site.sh /usr/local/bin/deploy-site.sh
sudoedit /etc/webhook/hooks.json
sudo systemctl restart webhook
sudo journalctl -u webhook -n 20 --no-pager
webhook refuses to start if the file is not valid JSON, is a link, is not owned by root or is writable by anyone but root, and it says why in journalctl -u webhook. The command runs in /var/lib/webhook, the only place the webhook user can write.
Step 7: What a hook command can and cannot do
Hook commands are the reason webhook exists, so the sandbox around them is the main security setting. On this image:
- Hook commands run as the
webhookuser, inside webhook's own systemd service, with the same limits as webhook itself. - What they cannot do. No
sudoorsu(no new privileges), no capabilities, a read-only system outside/var/lib/webhook, a private/tmp, and no route to the instance metadata service (169.254.169.254 and its IPv6 addressfd00:ec2::254). Thewebhookaccount has no login shell, is in no group but its own (notdockerorsudo), cannot usecron,at,systemd-runor lingering user services, and SSH refuses it, so a hook cannot start a process outside the sandbox. It cannot write/etc/webhook/hooks.jsonor the demo script, and the credentials file, the TLS key and the SSH host keys are readable by root only. - What they can still reach: the network. Only the instance metadata service is blocked. A hook command can open outbound connections to the internet and to anything in your VPC that your security groups and network ACLs allow (other instances, internal load balancers, databases, the VPC DNS resolver). This image adds no firewall for private addresses, so limit the instance's outbound security group rules (and the inbound rules of what it could reach) to what your hooks need.
- Keep the hop limit at 1. The metadata block covers the webhook service. A metadata hop limit of 1 (Step 1) keeps any container you later run on this host from fetching IMDSv2 tokens as well.
- Attach no instance profile, or a least-privilege one. Other software on the instance, and root, can still reach the metadata service, so an instance profile's permissions are only as safe as the instance. If a hook needs AWS access, give its script narrowly scoped credentials of its own.
The sandbox and the metadata block, as systemd applies them to webhook (and so to every hook command) and nginx:
systemctl show webhook -p User -p NoNewPrivileges -p ProtectSystem -p ProtectHome -p PrivateTmp -p CapabilityBoundingSet -p UMask
systemctl show webhook nginx -p Id -p IPAddressDeny
UMask=0027
CapabilityBoundingSet=
User=webhook
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
NoNewPrivileges=yes
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
Id=webhook.service
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
Id=nginx.service
The webhook account, the hooks file and the ways out of the sandbox that are closed:
id webhook
getent passwd webhook | cut -d: -f7
sudo stat -c '%U:%G %a %n' /etc/webhook /etc/webhook/hooks.json /usr/local/bin/webhook-demo.sh
cat /etc/cron.allow
sudo sshd -T | grep -iE '^(denyusers|passwordauthentication|permitrootlogin)'
uid=997(webhook) gid=987(webhook) groups=987(webhook)
/usr/sbin/nologin
root:webhook 750 /etc/webhook
root:webhook 640 /etc/webhook/hooks.json
root:root 755 /usr/local/bin/webhook-demo.sh
root
permitrootlogin no
passwordauthentication no
denyusers webhook
Step 8: Your own host name and certificate
On every boot webhook-set-host works out the name to serve: the name you set, else the instance's public DNS name from the instance metadata service (IMDSv2):
systemctl is-enabled webhook-set-host.service
sudo webhook-set-hostname --boot
enabled
webhook-set-hostname: unchanged (ec2-203-0-113-10.compute-1.amazonaws.com)
Use your own DNS name. Point a DNS record at the instance (an Elastic IP keeps the address fixed), then run sudo webhook-set-hostname hooks.example.com. The name is kept in /etc/cloudimg/webhook-hostname for every later boot, the credentials file is updated, nginx serves that name and redirects the instance's other addresses to it, and requests that a browser sends from other sites are refused. Because the certificate in place was the one cloudimg generated, it is re-created for the new name.
Which certificates are replaced. Only a certificate cloudimg generated is ever re-created: its SHA-256 fingerprint is recorded, root owned, in /var/lib/cloudimg/webhook-cert.sha256, and a certificate is treated as cloudimg's only while it matches that record:
sudo cat /var/lib/cloudimg/webhook-cert.sha256
openssl x509 -in /etc/ssl/webhook/webhook.crt -noout -fingerprint -sha256
15:1A:95:B3:94:79:1A:2D:4C:0E:A8:91:DC:F4:BC:AF:C8:80:66:6B:22:BD:AA:50:89:ED:C0:2C:F3:0E:BB:1B
sha256 Fingerprint=15:1A:95:B3:94:79:1A:2D:4C:0E:A8:91:DC:F4:BC:AF:C8:80:66:6B:22:BD:AA:50:89:ED:C0:2C:F3:0E:BB:1B
Install your own certificate (from your CA, or Let's Encrypt with a DNS challenge) by putting the full chain and the key at the same paths. Anything there that does not match the record is yours and is never replaced, not on boot and not when the name changes. A Git host or CI service calling your hooks will usually need a certificate it already trusts:
sudo install -m 0644 -o root -g root fullchain.pem /etc/ssl/webhook/webhook.crt
sudo install -m 0600 -o root -g root privkey.pem /etc/ssl/webhook/webhook.key
sudo nginx -t && sudo systemctl reload nginx
Go back to a generated certificate by removing both files and running the helper with the name to serve:
$ sudo rm /etc/ssl/webhook/webhook.crt /etc/ssl/webhook/webhook.key
$ sudo webhook-set-hostname hooks.example.com
To return to the instance's own public name on every boot, sudo rm /etc/cloudimg/webhook-hostname && sudo webhook-set-hostname --boot.
Step 9: Backups and updates
webhook keeps no data of its own: back up /etc/webhook/hooks.json (it holds your tokens, so keep the copy as safe as the instance) and the scripts your hooks run, or take an EBS snapshot of the root volume.
Ubuntu packages, nginx included, are updated by unattended security upgrades. webhook is a single program at /usr/local/bin/webhook, which unattended upgrades does not touch; cloudimg publishes a new version of this listing for new webhook releases and Go security fixes:
systemctl is-enabled unattended-upgrades apt-daily-upgrade.timer
enabled
enabled
Security notes
- Token first. A request to a hook without its token, with a wrong one, or with the token in the query string or the request body gets
403, and the command does not run. The token is compared from theX-Webhook-Tokenheader only. Anything outside/hooks/is answered by nginx and never reaches webhook. - Exact listeners. Only 22, 80 and 443 listen on the network (Step 3); webhook is on loopback. Port 80 only redirects to HTTPS. Open the ports in the security group only to the systems that need them.
- Rate limits. nginx allows each client address 5 hook requests a second (with a burst of 20) and answers more with
429, whatever spelling of the path is used. - Logs without secrets. nginx's access log records the path but never the query string. webhook's own log (
journalctl -u webhook) records each request's path and query string, so never put a token in a URL. - Forwarded headers. nginx sends webhook the configured host name,
https, port 443 and the real client address (X-Forwarded-Foris set from the connection, never passed through), and drops client-suppliedForwardedheaders. Responses carry HSTS. - Instance metadata. webhook, its hook commands, nginx, first boot and the helper commands are denied the EC2 instance metadata service by systemd (Step 7). Launch with IMDSv2 required and a hop limit of 1, as in Step 1.
- The network is not blocked. Apart from the metadata service, hook commands can reach the internet and your VPC (Step 7). Restrict the instance's outbound security group rules to what your hooks need.
- Secrets per instance. The trigger token, the HTTPS certificate and its key, and the SSH host keys are all created on your instance's first boot; none is in the image.
Build provenance and licence
webhook 2.8.3 is built by cloudimg from the upstream release tag (commit f78eeeca7d2d5f4af2e792d1df5c6e2b0c922dad) without any change to its source or its bundled Go modules, with Go 1.27.3, and checked with govulncheck, which finds no known vulnerability in the binary. webhook runs with the GODEBUG settings shown below, which restore Go 1.27's own request limits (the upstream source still asks for Go 1.21's defaults, which switch them off). The build record and the licence are on the image:
head -3 /usr/share/doc/cloudimg/webhook-BUILD-INFO.txt
sha256sum /usr/local/bin/webhook
ls /usr/share/doc/cloudimg/webhook
webhook 2.8.3 on Ubuntu 24.04 (cloudimg AWS bundle member dev-cicd)
binary /usr/local/bin/webhook sha256 1d9a6cfe191fd985d2a9e555b14c951816956ef5eaa3357b822dce12ac32e4c2: cloudimg go1.27.3 build of the unmodified tag 2.8.3 (commit f78eeeca7d2d5f4af2e792d1df5c6e2b0c922dad); MIT
GODEBUG urlmaxqueryparams=10001,httpcookiemaxnum=3000,httplaxcontentlength=0,urlstrictcolons=1,tlssha1=0,x509negativeserial=0,rsa1024min=1
1d9a6cfe191fd985d2a9e555b14c951816956ef5eaa3357b822dce12ac32e4c2 /usr/local/bin/webhook
BUILD-PROVENANCE.txt
LICENSE
SOURCE
buildinfo.txt
govulncheck-binary.txt
webhook is MIT licensed; the Go modules compiled into it are MIT, BSD-3-Clause or Apache-2.0 licensed. Source: github.com/adnanh/webhook (tag 2.8.3). webhook is an open source project by Adnan Hajdarevic; cloudimg is not affiliated with or endorsed by the webhook project.
Troubleshooting
403 Hook rules were not satisfied.The token is missing or wrong, or it was sent somewhere other than theX-Webhook-Tokenheader. Read it again from/etc/cloudimg-credentials.txt. Afterwebhook-set-tokenthe old one is refused.405 Method Not Allowed. Hooks acceptPOSTonly.429 Too Many Requests. Too many hook requests from one address in a short time. Slow the caller down.404 Not Found. The address is not under/hooks/, or no hook has that id. Hook ids are case sensitive.- The connection is dropped with no answer. The request named a host the instance does not serve. Use the name in
WEBHOOK_HOST, or set yours withwebhook-set-hostname(Step 8). - curl: (60) SSL certificate problem. The caller does not trust the instance's self-signed certificate. Give it the certificate with
--cacert(Step 5) or install one it trusts (Step 8). - webhook does not start after an edit.
sudo journalctl -u webhook -n 30shows why: invalid JSON, or the file's owner or permissions (Step 6). - A hook command cannot reach the metadata service, use
sudoor write outside/var/lib/webhook. That is the sandbox (Step 7). - Logs:
journalctl -u webhook,/var/log/nginx/access.logand/var/log/nginx/error.log, and/var/log/cloudimg/webhook-firstboot.log.
Support
cloudimg images come with 24/7 support. Email support@cloudimg.co.uk with your AWS account ID, the instance ID and the output of sudo /usr/local/sbin/webhook-verify.sh. For webhook itself, see the webhook documentation.