A GitHub webhook fires when a specific event occurs in your repository — such as a push — and delivers an HTTP POST payload to a URL you control. This makes webhooks a powerful primitive for automating DevOps workflows: triggering backups, running builds, or kicking off deployments without a heavyweight CI platform.
This article walks through setting up a webhook receiver from scratch: configuring Apache with mod_cgi, writing CGI-compatible Bash scripts, parsing the GitHub JSON payload with jq, and building two practical hooks — a simple repository backup and a build-and-package hook.
How GitHub Webhooks Work
When a webhook-triggering event occurs (for example, a push to a repository), GitHub sends an HTTP POST request to the URL you have registered in the repository’s webhook settings. The request body contains a JSON payload describing the event — including details about the repository, the commits pushed, and the actor who triggered it.
Your webhook endpoint must:
- Accept an HTTP POST request.
- Read and parse the JSON body.
- Return a valid HTTP response (typically
200 OK) quickly, before GitHub times out the request. - Perform whatever automation logic you need.
Preparing Apache with mod_cgi
Apache’s mod_cgi module lets the web server execute server-side scripts and return their standard output as the HTTP response. It is available in the default repositories of all major Linux distributions.
Enable mod_cgi
On Debian/Ubuntu-based systems, enable the module with:
sudo a2enmod cgi
sudo systemctl restart apache2
On RHEL/CentOS-based systems, mod_cgi is typically compiled in. Confirm it is loaded in your Apache configuration and restart httpd if you make changes.
Create the Directory Structure
Use /opt/hooks as the root for all webhook-related files. Inside it, create two subdirectories:
/opt/hooks/html— the virtual host document root./opt/hooks/cgi-bin— where the Bash webhook scripts live.
sudo mkdir -p /opt/hooks/html /opt/hooks/cgi-bin
Set Directory Ownership
Apache must be able to read and execute files in these directories. Find the user Apache is running as:
ps -e -o %U%c | grep 'apache2\|httpd'Code language: JavaScript (javascript)
The first column shows the username (commonly www-data, apache, or nobody). Grant ownership of the hooks directory tree to that user:
sudo chown -R www-data /opt/hooks
Repeat this command any time you add new files or subdirectories under /opt/hooks.
Configure the Virtual Host
Add the following virtual host block to your Apache configuration (for example, in /etc/apache2/sites-available/hooks.conf on Debian/Ubuntu, or a file in /etc/httpd/conf.d/ on RHEL-based systems). Replace SERVERNAME with the hostname or IP address that GitHub will POST to:
<VirtualHost *:80>
ServerName SERVERNAME
ScriptAlias "/cgi-bin" "/opt/hooks/cgi-bin"
DocumentRoot /opt/hooks/html
</VirtualHost>Code language: HTML, XML (xml)
Key directives:
- DocumentRoot — serves static files from
/opt/hooks/html. - ScriptAlias — maps the URL path
/cgi-binto/opt/hooks/cgi-binon disk, and instructsmod_cgito execute any scripts found there.
After saving, enable the site and reload Apache:
sudo a2ensite hooks.conf
sudo systemctl reload apache2Code language: CSS (css)
Note: This example uses plain HTTP on port 80 for clarity. In production you should configure TLS (HTTPS) and use GitHub’s webhook secret to verify request authenticity.
CGI Basics: HTTP Requests and Bash Scripts
CGI (Common Gateway Interface) is a standard for web servers to execute external programs and return their output as an HTTP response. When Apache receives a request for a path covered by ScriptAlias, it executes the corresponding script and sends its standard output back to the client as the HTTP response body — headers included.
Here is an example of a GitHub webhook POST request and the expected response:
Example Request
POST /cgi-bin/clone.cgi HTTP/1.1
Host: hooks.example.com
Content-Length: 86
{"repository":{"name":"webhook-test","url":"https://github.com/user/webhook-test"}}Code language: HTTP (http)
Example Response
HTTP/1.1 200 OK
Content-Type: text/json
Content-Length: 18
{"success":"true"}Code language: HTTP (http)
A minimal Bash CGI script that illustrates this pattern looks like this:
#!/bin/bash
# Read the POST body from standard input
JSONPOST="$(cat -)"
# Output HTTP response headers followed by a blank line
echo "Content-Type: text/json"
echo ""
# Output the response body
echo '{"success":"true"}'Code language: PHP (php)
Two things to note:
- The POST body arrives on standard input.
cat -reads it into a variable. - The script must output at least one HTTP header (
Content-Type) followed by a blank line before any response body. Apache handles the status line itself.
Scripts must be executable. Set the permission bit after creating each script:
chmod +x /opt/hooks/cgi-bin/myhook.cgi
Parsing the GitHub JSON Payload with jq
GitHub’s push event payload is a JSON object. The most practical tool for extracting fields from JSON in a Bash script is jq, available in the default repositories of all major Linux distributions.
Consider this excerpt from a real push payload:
{
"repository": {
"name": "webhook-test",
"git_url": "git://github.com/user/webhook-test.git",
"ssh_url": "git@github.com:user/webhook-test.git",
"clone_url": "https://github.com/user/webhook-test.git"
},
"commits": [
{
"timestamp": "2019-06-11T02:44:52Z"
}
]
}Code language: JSON / JSON with Comments (json)
To extract the HTTPS clone URL, pipe the JSON string into jq using dot notation:
jq -r '.repository.clone_url' <<< "$POSTJSON"Code language: JavaScript (javascript)
The -r flag outputs raw strings without surrounding quotes, which makes it easy to assign the result to a shell variable:
REPOURL="$(jq -r '.repository.clone_url' <<< "$POSTJSON")"
REPONAME="$(jq -r '.repository.name' <<< "$POSTJSON")"Code language: PHP (php)
Hook #1: Simple Repository Backup
This hook clones a repository to the server the first time a push event fires, and runs git pull on subsequent pushes to keep the local copy current. Save it as /opt/hooks/cgi-bin/backup.cgi and make it executable.
#!/bin/bash
# Directory where repository clones are stored
REPODIR="/opt/hooks/html/repos"
# Helper: emit a JSON result object based on the exit code passed as $1
json_resp() {
echo '{"result":"'"$([ $1 -eq 0 ] && echo "success" || echo "failure")"'"}'
}
# Read GitHub POST payload from stdin
POSTJSON="$(cat -)"
# Parse the fields we need
REPOURL="$(jq -r '.repository.clone_url' <<< "$POSTJSON")"
REPONAME="$(jq -r '.repository.name' <<< "$POSTJSON")"
# Output HTTP response headers
echo "Content-Type: text/json"
echo ""
# Clone or update
if [ -d "$REPODIR/$REPONAME" ]; then
# Repository already exists — pull latest changes
pushd . > /dev/null
cd "$REPODIR/$REPONAME"
git pull
json_resp $?
popd > /dev/null
else
# First run — clone the repository
mkdir -p "$REPODIR/$REPONAME"
git clone "$REPOURL" "$REPODIR/$REPONAME"
json_resp $?
fiCode language: PHP (php)
How it works:
- REPODIR — the parent directory for all cloned repositories.
- json_resp — a helper function that converts a shell exit code (0 = success, non-zero = failure) into a JSON response body.
- POSTJSON — the raw GitHub payload, captured from standard input.
- The
ifblock checks whether a local clone already exists. If it does,git pullupdates it. If not,git clonecreates it. - The exit code of the git command is forwarded to
json_resp, so GitHub receives a meaningful success or failure indicator.
Hook #2: Build and Package
This hook extends the backup concept: after cloning or pulling, it runs make to build the project and packages the output into a timestamped .tar.gz archive served from the web root. This is a lightweight foundation for a CI/CD pipeline when a full CI platform is not available or desired.
The hook assumes the repository’s Makefile follows two conventions:
- Running
makeplaces build artifacts into abuild/subdirectory inside the repository. - Running
make cleanremoves thebuild/subdirectory.
Save this as /opt/hooks/cgi-bin/build.cgi and make it executable:
#!/bin/bash
WEBROOT="/opt/hooks/html/archive"
REPODIR="/opt/hooks/html/repos"
WEBURL="http://hooks.example.com/archive"
# Emit a JSON response with result and, on success, the archive URL
json_package() {
local exit_code=$1
local url=$2
if [ "$exit_code" -eq 0 ]; then
echo "{\"result\":\"success\",\"url\":\"$url\"}"
else
echo '{"result":"package failure"}'
fi
}
# Run make and package the build output
run_make() {
local clone_exit=$1
# Clean any previous build
[ -d "$REPODIR/$REPONAME/build" ] && make -s -C "$REPODIR/$REPONAME" clean
if [ "$clone_exit" -eq 0 ]; then
make -s -C "$REPODIR/$REPONAME"
if [ -d "$REPODIR/$REPONAME/build" ]; then
FILENAME="$REPONAME-$COMMITTIME.tar.gz"
tar -czf "$WEBROOT/$FILENAME" -C "$REPODIR/$REPONAME/build" .
json_package "$?" "$WEBURL/$FILENAME"
else
echo '{"result":"build failure"}'
fi
else
echo '{"result":"clone/pull failure"}'
fi
}
# Read GitHub POST payload
POSTJSON="$(cat -)"
# Parse required fields
REPOURL="$(jq -r '.repository.clone_url' <<< "$POSTJSON")"
REPONAME="$(jq -r '.repository.name' <<< "$POSTJSON")"
COMMITTIME="$(date -d "$(jq -r '.commits[0].timestamp' <<< "$POSTJSON")" +"%m-%d-%YT%H-%M-%S")"
# Ensure archive directory exists
mkdir -p "$WEBROOT"
# Output HTTP response headers
echo "Content-Type: text/json"
echo ""
# Clone or update, then build
if [ -d "$REPODIR/$REPONAME" ]; then
pushd . > /dev/null
cd "$REPODIR/$REPONAME"
git pull
run_make $?
popd > /dev/null
else
mkdir -p "$REPODIR/$REPONAME"
git clone "$REPOURL" "$REPODIR/$REPONAME"
run_make $?
fiCode language: PHP (php)
Key points:
- COMMITTIME — the timestamp from the first commit in the push payload, reformatted with
dateso it contains no spaces or colons that would be invalid in a filename. - run_make — receives the exit code of the preceding
git cloneorgit pull. If that succeeded, it runsmake. If abuild/directory appears, it archives its contents withtarand returns a JSON object containing the result and the download URL. - json_package — on success returns both a
resultand aurlattribute so the caller can locate the artifact immediately.
Registering the Webhook in GitHub
Once your server is reachable from the internet (or from GitHub’s webhook IP ranges), register the endpoint in your repository:
- Go to your repository on GitHub → Settings → Webhooks → Add webhook.
- Set Payload URL to
http://your-server/cgi-bin/backup.cgi(orbuild.cgi). - Set Content type to
application/json. - Choose the Just the push event trigger.
- Click Add webhook. GitHub will send a ping event; check the delivery log to confirm your server responded with
200 OK.
Security Considerations
The examples above are intentionally minimal. Before deploying a webhook receiver to a production or internet-facing server, address the following:
- Use HTTPS. Configure TLS on the virtual host so the payload is encrypted in transit.
- Validate the GitHub signature. GitHub can sign each request with a shared secret using HMAC-SHA256. Your script should verify the
X-Hub-Signature-256header before acting on the payload. Without this check, any party that can reach your endpoint can trigger your automation. - Quote all variables. The examples above use quoted variables (
"$REPONAME") to guard against word splitting. Double-check this in your own scripts before running them with Apache’s process privileges. - Limit Apache process permissions. The Apache user should have only the minimum filesystem permissions required.
- Sanitize parsed values. Values extracted from the JSON payload should be validated before being used in shell commands to prevent command injection.
Summary
GitHub webhooks, combined with Apache mod_cgi and Bash, provide a lightweight and flexible way to automate repository-driven workflows without a dedicated CI/CD platform. The pattern is straightforward:
- Configure Apache with
mod_cgiand aScriptAliaspointing to your scripts directory. - Write a Bash script that reads
stdin(the POST body), outputs HTTP headers, performs the desired action, and returns a JSON result. - Use
jqto extract fields from the GitHub payload. - Register the endpoint URL as a webhook in GitHub’s repository settings.
From here you can extend either hook — adding Slack notifications, deploying to remote servers over SSH, running test suites, or triggering package uploads — all within Bash and standard Unix tooling.
Further Reading
- GitHub Webhooks Documentation
- Apache mod_cgi Documentation
- Apache mod_alias Documentation (covers the
ScriptAliasdirective) - jq Manual
This article is based on a piece originally written by Andy Carlson for Linux Journal.