Writing GitHub Webhooks with Bash and Apache CGI

GitHub

YOUR COSMETIC CARE STARTS HERE

Find the Best Cosmetic Hospitals

Trusted • Curated • Easy

Looking for the right place for a cosmetic procedure? Explore top cosmetic hospitals in one place and choose with confidence.

“Small steps lead to big changes — today is a perfect day to begin.”

Explore Cosmetic Hospitals Compare hospitals, services & options quickly.

✓ Shortlist providers • ✓ Review options • ✓ Take the next step with confidence

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-bin to /opt/hooks/cgi-bin on disk, and instructs mod_cgi to 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 if block checks whether a local clone already exists. If it does, git pull updates it. If not, git clone creates 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 make places build artifacts into a build/ subdirectory inside the repository.
  • Running make clean removes the build/ 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 date so it contains no spaces or colons that would be invalid in a filename.
  • run_make — receives the exit code of the preceding git clone or git pull. If that succeeded, it runs make. If a build/ directory appears, it archives its contents with tar and returns a JSON object containing the result and the download URL.
  • json_package — on success returns both a result and a url attribute 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:

  1. Go to your repository on GitHub → Settings → Webhooks → Add webhook.
  2. Set Payload URL to http://your-server/cgi-bin/backup.cgi (or build.cgi).
  3. Set Content type to application/json.
  4. Choose the Just the push event trigger.
  5. 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-256 header 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:

  1. Configure Apache with mod_cgi and a ScriptAlias pointing to your scripts directory.
  2. Write a Bash script that reads stdin (the POST body), outputs HTTP headers, performs the desired action, and returns a JSON result.
  3. Use jq to extract fields from the GitHub payload.
  4. 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

This article is based on a piece originally written by Andy Carlson for Linux Journal.

0 0 votes
Article Rating
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x