GitLab webhooks let you auto-deploy on push by sending a JSON POST to an endpoint you host. A small PHP receiver validates the request, then runs a shell script that pulls the code and restarts the app. The non-negotiable security rule: verify the X-Gitlab-Token secret header with a constant-time compare before doing anything, read the raw body from php://input, check the branch, and run a fixed deploy script as a least-privilege user - never pass webhook data into the shell. For new projects in 2026, prefer a GitLab CI/CD pipeline (.gitlab-ci.yml); the webhook-to-shell pattern below is a lightweight legacy option that still works well on a single box.
Key takeaways
- A GitLab webhook fires a JSON
POSTto your URL on events such as push, tag, merge request, issue and pipeline. - Authenticate every call by comparing the
X-Gitlab-Tokenheader to your configured secret withhash_equals()(constant-time). An unauthenticated deploy endpoint is a remote-code-execution risk. - Read the body with
file_get_contents('php://input')andjson_decode(..., JSON_THROW_ON_ERROR); gate onX-Gitlab-Eventand the branchrefbefore acting. - Run a single, hard-coded deploy script as a dedicated user with a narrow
NOPASSWDsudoers rule. Never interpolate webhook data into a shell command. - The modern, recommended path is a GitLab CI/CD pipeline with a deploy job and a GitLab Runner - use webhooks-to-shell only for tiny, legacy single-server setups.
How do GitLab webhooks work?
In your project, go to Settings -> Webhooks, add the URL of your PHP endpoint, set a Secret token, and tick the events you care about (usually Push events, optionally filtered to a branch). On each matching event GitLab sends an HTTPS POST with a JSON body and a couple of identifying headers. Your job is to authenticate the call, confirm it is the event and branch you want, and only then trigger work.
| Header / field | Purpose |
|---|---|
X-Gitlab-Event |
Event type, e.g. Push Hook, Tag Push Hook, Merge Request Hook |
X-Gitlab-Token |
Your configured secret - verify it with a constant-time compare before anything else |
Content-Type: application/json |
The body is JSON; read it from php://input, not $_POST |
ref (in body) |
Branch or tag ref that changed, e.g. refs/heads/main |
checkout_sha (in body) |
The commit the push landed on - handy for logging |
The original version of this guide passed the token as a ?token= query string and checked the caller's IP against a hard-coded list. Modern GitLab sends the secret in the X-Gitlab-Token header, and IP allow-lists are brittle (SaaS runners and self-managed instances change addresses). The hardened receiver below uses the header secret instead.
A hardened PHP 8 webhook receiver
This receiver does five things in order: rejects non-POST requests, verifies the secret token in constant time, decodes the JSON body safely, checks the event and branch, then executes a fixed deploy script. No value from the request is ever passed to the shell, so there is nothing to inject. The secret is read from the environment, never hard-coded in a file that lives in the web root.
<?php
// /var/www/hooks/deploy.php - GitLab webhook receiver (PHP 8.x). Serve over HTTPS only.
declare(strict_types=1);
const DEPLOY_SCRIPT = '/opt/deploy/deploy.sh'; // fixed path, never taken from the request
const ALLOWED_BRANCH = 'refs/heads/main';
const LOG_FILE = '/var/log/gitlab-webhook/webhook.log';
const DEPLOY_USER = 'deploy'; // dedicated, least-privilege OS user
$secret = getenv('GITLAB_WEBHOOK_SECRET') ?: ''; // set via the PHP-FPM pool env, not in code
function audit(string $line): void {
@file_put_contents(LOG_FILE, '[' . date('c') . '] ' . $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
function reply(int $code, string $msg): never {
http_response_code($code);
header('Content-Type: text/plain');
echo $msg;
exit;
}
// 1) Only accept POST.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
reply(405, 'Method Not Allowed');
}
// 2) Validate the GitLab secret token with a constant-time compare.
$token = $_SERVER['HTTP_X_GITLAB_TOKEN'] ?? '';
if ($secret === '' || !hash_equals($secret, $token)) {
audit('Rejected: invalid X-Gitlab-Token');
reply(401, 'Unauthorized');
}
// 3) Read the RAW body and decode JSON safely.
$raw = file_get_contents('php://input') ?: '';
try {
$payload = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
} catch (JsonException) {
audit('Rejected: malformed JSON body');
reply(400, 'Bad Request');
}
// 4) Act only on push events to the release branch.
$event = $_SERVER['HTTP_X_GITLAB_EVENT'] ?? '';
$ref = is_array($payload) ? ($payload['ref'] ?? '') : '';
if ($event !== 'Push Hook' || $ref !== ALLOWED_BRANCH) {
audit("Ignored event [$event] ref [$ref]");
reply(202, 'Accepted (no action)');
}
// 5) Run the FIXED deploy script as the deploy user, least privilege. No request
// data is ever passed to the shell, so there is nothing to inject.
exec('sudo -n -u ' . escapeshellarg(DEPLOY_USER) . ' ' . escapeshellarg(DEPLOY_SCRIPT) . ' 2>&1', $out, $rc);
audit('Deploy rc=' . $rc . ' sha=' . ($payload['checkout_sha'] ?? 'unknown'));
reply($rc === 0 ? 200 : 500, $rc === 0 ? 'Deployed' : 'Deploy failed');
A few things worth calling out. hash_equals() is used instead of === so the comparison does not leak timing information about the secret. The secret comes from getenv() - set it in the PHP-FPM pool with env[GITLAB_WEBHOOK_SECRET] = ... and clear_env = no, or via your process manager, so it never sits in a web-served file. We respond with 202 (rather than an error) for events we deliberately ignore, which keeps GitLab's webhook delivery log green.
The deploy shell script
Keep the script outside the web root, owned by root, mode 0750, and runnable by the deploy user. It takes no arguments - everything it needs is hard-coded - so even a compromised receiver cannot make it do anything new.
#!/usr/bin/env bash
# /opt/deploy/deploy.sh - pull the release branch and reload the app. Takes NO arguments.
set -Eeuo pipefail
APP_DIR="/var/www/myapp"
BRANCH="main"
cd "$APP_DIR"
# Deterministic update: fetch and hard-reset to the remote tip (no merge surprises).
git fetch --prune origin
git reset --hard "origin/${BRANCH}"
git clean -fd
# Install dependencies for production.
composer install --no-dev --optimize-autoloader --no-interaction
# Reload PHP-FPM via a single, allow-listed systemctl command.
sudo -n /usr/bin/systemctl reload php8.3-fpm
echo "Deployed origin/${BRANCH} @ $(git rev-parse --short HEAD)"
Use SSH with a deploy key (an empty-passphrase key dedicated to this server, or better a GitLab deploy token / deploy key with read-only scope) so git fetch runs non-interactively. The directory must already be a configured git clone of the repo. For background on the underlying PHP-FPM and Nginx stack, see PHP hosting on Ubuntu with Nginx.
Least-privilege sudo and the Nginx endpoint
The original guide granted user ALL=(ALL) NOPASSWD:/path/to/script.sh - that lets the web user run the script as any user, including root. Tighten it: the web server user may run only the one script, only as the deploy user, and the deploy user may reload only the one service. Edit the file with sudo visudo (or a file under /etc/sudoers.d/).
# /etc/sudoers.d/gitlab-deploy (edit with: sudo visudo -f /etc/sudoers.d/gitlab-deploy)
# Web server user may run ONLY the deploy script, ONLY as the deploy user, no password.
www-data ALL=(deploy) NOPASSWD: /opt/deploy/deploy.sh
# The deploy user may reload ONLY the PHP-FPM service, no password.
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm
Expose only the receiver, over HTTPS, ideally on a hard-to-guess path, and serve nothing else from the hooks directory. Nginx forwards the request to PHP-FPM; the X-Gitlab-* headers arrive as HTTP_X_GITLAB_* server variables automatically.
# Nginx: expose ONLY the webhook receiver via PHP-FPM, over HTTPS.
location = /hooks/deploy.php {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME /var/www/hooks/deploy.php;
# X-Gitlab-Token / X-Gitlab-Event are passed through as HTTP_* params by default.
}
# Never serve any other file from the hooks directory.
location /hooks/ { return 404; }
Which user runs the deploy, and how do I debug it?
The deploy is triggered by the web server's PHP-FPM worker, so the OS user is typically www-data on Debian/Ubuntu (it can be nginx or nobody on other setups). If exec() silently does nothing, the PHP runs to the last line but the shell call fails - almost always a sudoers or permissions issue. Watch it live: push a commit, then on the server run journalctl -fu php8.3-fpm and tail your webhook.log. Confirm the effective user with a quick whoami in the script, then make sure that exact user matches the sudoers rule. After editing sudoers, no restart is needed, but the script's own permissions and group membership must allow execution.
Webhook + shell script vs GitLab CI/CD: which should you use?
Be honest with yourself about scope. A webhook-to-shell receiver is quick to stand up and fine for a hobby box or a single legacy server. For anything a team relies on, a GitLab CI/CD pipeline is the recommended 2026 default: secrets live in masked, protected CI/CD variables, there is no public PHP endpoint to harden, and you get per-job logs, environments and one-click rollbacks.
| Aspect | Webhook + PHP/shell receiver | GitLab CI/CD pipeline |
|---|---|---|
| Control over steps | Manual PHP + shell glue you maintain | Declarative .gitlab-ci.yml with stages, environments, manual gates |
| Security | You own auth, input handling and sudo scope - easy to get wrong | Secrets in masked/protected variables; scoped runner; no public endpoint |
| Visibility | A log file you tail yourself | Per-job logs, status and environments in the GitLab UI |
| Complexity to start | Low - one PHP file plus a script | Needs a GitLab Runner, but config is reusable across projects |
| Rollback / audit | Do it yourself | Built-in: re-run a previous pipeline, protected environments |
| Recommendation | Legacy or tiny single-box setups only | Recommended default for new projects |
The modern alternative: a GitLab CI/CD deploy job
With a registered GitLab Runner, this pipeline deploys on every push to main by SSHing in and running the same hardened deploy script - no public webhook endpoint required. The SSH key and known-hosts are stored as masked, protected CI/CD variables, never in the repo.
# .gitlab-ci.yml - the modern, recommended way to deploy on push.
stages:
- deploy
deploy_production:
stage: deploy
environment:
name: production
url: https://example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"' # only on pushes to main
before_script:
- eval "$(ssh-agent -s)"
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
script:
- ssh deploy@example.com '/opt/deploy/deploy.sh'
If you authenticate users or pull project data from GitLab elsewhere in your stack, the same secret-handling discipline applies - see integrating the GitLab API in a Django project. And if you would rather not run, patch and monitor deploy plumbing yourself, our server maintenance services team sets up hardened CI/CD, webhooks and zero-downtime deploys for you. MicroPyramid has shipped software since 2014 - 12+ years and 50+ projects across Python, PHP, DevOps and AWS.
Frequently Asked Questions
How do I secure a GitLab webhook receiver?
Verify the secret first. Compare the incoming X-Gitlab-Token header to your configured secret with a constant-time function (hash_equals() in PHP) and reject the request with 401 if it does not match. Then read the raw body from php://input, decode it with json_decode(..., JSON_THROW_ON_ERROR), check the X-Gitlab-Event type and the branch ref, and only run a fixed deploy script - never one built from request data. Serve the endpoint over HTTPS and run the deploy under a least-privilege user.
What is the X-Gitlab-Token header for?
It is the shared secret you set when creating the webhook in Settings -> Webhooks. GitLab sends it on every delivery in the X-Gitlab-Token header so your endpoint can prove the request really came from your GitLab instance. Without checking it, anyone who learns your URL could trigger a deploy, which is effectively remote code execution. Compare it in constant time and store the expected value in an environment variable, not in code.
Should I use GitLab webhooks or GitLab CI/CD to deploy?
For new projects in 2026, use GitLab CI/CD. A .gitlab-ci.yml deploy job with a GitLab Runner gives you secret management, per-job logs, environments and rollbacks without exposing a public PHP endpoint you have to harden. The webhook-plus-shell pattern is a lightweight legacy option that is fine for a single hobby or legacy box, but CI/CD is the recommended default for anything a team depends on.
Why shouldn't I pass webhook data into a shell script?
Because webhook payloads are attacker-influenced input, and interpolating them into a shell command invites command injection. The safe pattern is a deploy script that takes no arguments and hard-codes its repo path, branch and commands; the PHP receiver only decides whether to run that fixed script. If you ever must pass a value, escape it with escapeshellarg() and validate it against a strict allow-list first.
Which user runs the deploy script and how do I set sudo permissions?
The deploy is launched by the PHP-FPM worker, so the OS user is usually www-data on Ubuntu/Debian (sometimes nginx or nobody). Grant a narrow rule with visudo: allow that web user to run only the one script, only as a dedicated deploy user, with NOPASSWD, and let the deploy user reload only the specific service. Avoid ALL=(ALL) - it would let the web user run the script as root.
How do I test that my GitLab webhook is working?
Use the Test button in Settings -> Webhooks to send a sample push event, then check the delivery's response code and body in GitLab's webhook log. On the server, tail your webhook.log and run journalctl -fu php8.3-fpm while you push a real commit. A 200 with Deployed, plus the new commit SHA in your app directory, confirms the full chain; a 401 means the token check failed, and a silent no-op after a 200 usually points to a sudoers or file-permission problem.