github-push-deploy
Deploy a website automatically whenever you push to GitHub, using a repo push webhook and a small PHP listener — no CI runner, no GitHub Actions, no third-party service.
It suits a plain Apache + PHP-FPM box where you can serve a directory and run shell: the classic "I have an SSH login and /var/www" setup.
How it works
- You add a webhook to your GitHub repo (Settings → Webhooks) pointing at a public URL that maps to
github-hook-listener.php. - On every push, GitHub POSTs the event to that listener, signed with a shared secret.
github-hook-listener.phpverifies the HMAC-SHA256 signature and checks the event is a push to the configured branch of the configured repo. If so, it runsupdate.sh.update.shclones the repo fresh and sources the repo's owndeploy.sh.deploy.shcopies the (possibly updated) machinery into place and publishes the site into the web root.
Because deploy.sh lives in your repo, a push can change how deployment itself works — the machinery updates itself.
All configuration lives in a single deploy.conf read by both the PHP listener and the shell scripts, so nothing is configured in two places.
Layout
Put the three machinery files in your website repository under github-push-deploy/:
your-repo/
├── github-push-deploy/
│ ├── github-hook-listener.php # the webhook receiver
│ ├── update.sh # clone + dispatch
│ └── deploy.sh # YOUR publish logic (customise this)
└── ... the rest of your site ...
On the server, one base directory per site holds everything else (this is BASE_DIR):
/var/www/tools/ # BASE_DIR — not served directly
├── deploy.conf # your config + secret (never served, never in git)
├── update.sh # copied here by deploy.sh
├── deploy.log # listener log
├── deploy-cmd.log # deploy output log
├── repo/ # fresh clone, recreated on each deploy
├── update-scripts/ # holds github-hook-listener.php — the ONLY web-exposed part
└── html/ # DocumentRoot — the published site
Only html/ (as DocumentRoot) and update-scripts/ (via an Alias) are reachable from the web. deploy.conf, the logs, update.sh and repo/ sit in BASE_DIR above both and are never served.
Setup
Examples below use tools / watercrossing/tools / tools.example.com; substitute your own.
-
Create the base directory and its
html/andupdate-scripts/subfolders:sudo mkdir -p /var/www/tools/{html,update-scripts}Make everything owned by the user PHP-FPM/Apache runs as (so it can clone, write logs and publish) — e.g.
sudo chown -R apache:apache /var/www/tools. -
Give the deploy user a way to clone. Create an SSH key for that user (e.g. in
/usr/share/httpd/.ssh), add it as a deploy key on the repo, and pointCLONE_URLat it. Using an SSH host alias keeps the URL tidy — in that user's~/.ssh/config:Host gh HostName github.com User git IdentityFile /usr/share/httpd/.ssh/id_ed25519then
CLONE_URL="gh:watercrossing/tools.git". (A public repo can use anhttps://URL and skip the key.) -
Write the config. Copy
deploy.conf.exampleto/var/www/tools/deploy.confand fill it in. Generate the secret withopenssl rand -hex 32(hex keeps it safe for both shell and PHP parsing). It holds the webhook secret, so lock it down so other local users can't read it:sudo chmod 600 /var/www/tools/deploy.conf(and make sure it's owned by the deploy user). -
Expose the listener in Apache. In your site's
<VirtualHost>, servehtml/as theDocumentRootand alias a public path toupdate-scripts/— this is the only extra Apache config the tool needs:DocumentRoot "/var/www/tools/html" Alias "/github-webhook/tools" "/var/www/tools/update-scripts" <Directory "/var/www/tools/update-scripts"> AllowOverride None Options -Indexes Require all granted </Directory>The listener is then at
https://tools.example.com/github-webhook/tools/github-hook-listener.php. -
Add the webhook on GitHub (repo → Settings → Webhooks → Add webhook):
- Payload URL: the listener URL from step 4.
- Content type:
application/json. - Secret: the same value as
WEBHOOK_SECRETindeploy.conf. - Events: just the push event.
-
Bootstrap once by hand, running as
apacheso every file created is owned by the deploy user from the start:sudo -u apache git clone gh:watercrossing/tools.git /var/www/tools/repo cd /var/www/tools/repo sudo -u apache env BASE_DIR=/var/www/tools bash github-push-deploy/deploy.shBASE_DIRis passed inline only because this manual run bypassesupdate.sh, which would otherwise source it fromdeploy.conf; it is the one valuedeploy.shneeds. (envis just the portable way to set it throughsudo.)
From now on, every push to main publishes automatically.
Configuration reference
All keys live in deploy.conf (see deploy.conf.example):
| Key | Meaning |
|---|---|
BASE_DIR |
Per-site base directory holding html/, update-scripts/, repo/, logs and this config. |
REPO_FULL_NAME |
owner/name of the repo allowed to trigger a deploy. |
BRANCH |
Only pushes to this branch deploy. |
CLONE_URL |
Git URL used to clone (SSH alias or full URL). |
DEPLOY_SCRIPT |
Path to the deploy script within the repo (default github-push-deploy/deploy.sh). |
WEBHOOK_SECRET |
Shared secret; must match the GitHub webhook's Secret field. |
Debugging
- The listener logs to
$BASE_DIR/deploy.log; the deploy output goes to$BASE_DIR/deploy-cmd.log. Both sit inBASE_DIR, so they are easy to find and are not web-accessible. - Run boundaries and timing.
update.shbrackets every run indeploy-cmd.logwith a marker, so successive deploys don't run into each other:
The closing marker is written from an===== deploy started 2026-07-29 14:57:02 +0100 ===== ...traced output of update.sh and deploy.sh... ===== deploy finished 2026-07-29 14:57:04 +0100 — exit 0 after 1.7s =====EXITtrap, so a failed deploy is still delimited and its exit status recorded.deploy.loggets the matching one-line summary from the listener (update.sh exited 0 after 1.7s). Manual runs print the same markers to the terminal. Elapsed time is reported to a tenth of a second. That needs GNUdate(for%1N); on adatewithout it the timing degrades to whole seconds — always.0— rather than failing the deploy. - Timestamps and timezone. Both logs stamp local time with its UTC offset (
2026-07-29 15:54:34 +0100), so lines from the two files line up. This needs saying because PHP does not follow the system zone: withdate.timezoneunset inphp.ini— the usual state —date()silently falls back to UTC, whileupdate.shuses GNUdateand the box's zone, and the same deploy ends up stamped an hour apart under BST. Rather than editingphp.ini, the listener adopts the system zone itself, reading/etc/localtime's symlink target (what the C library uses) and falling back to/etc/timezone. If neither is readable it stays on UTC — and because every line carries its offset, that shows up as+0000next to the shell's+0100instead of hiding. - Log trimming. Both logs are capped: whenever the listener handles a request, any log over 5 MB is cut back to its last 4 MB (at a line boundary) and marked with a
===== log trimmed … =====line. So each file stays between 4 MB and roughly 5 MB plus one deploy's output. - What is the site actually serving?
deploy.shwritesversion.txtinto the published tree, so you can check without an SSH login:
It is written into the staging tree before the swap, so it appears at the same instant as the content it describes rather than a moment later. Under publish strategy (B) it lands aftercurl -s https://tools.example.com/version.txt # e3804f8 2026-07-29T16:16:51+01:00 git log -1 --format='%h %cI' # the same, from your checkoutrepo-web-viewhas generated the pages, so it is not in any folder listing, and the generated.htaccessmarks it as a download — neither of which matters tocurl. - GitHub → repo → Settings → Webhooks → Recent Deliveries shows each POST, its response, and a Redeliver button to retry without pushing.
- Run the deploy path by hand as the deploy user:
sudo -u apache /var/www/tools/update.sh. - A
403means the signature failed (wrong/absent secret). A200withIgnoredmeans the signature was fine but it wasn't a push to the configured branch/repo. - Rejected (bad-signature) requests are not written to
deploy.logon purpose — the endpoint is public, so logging every probe would let anyone grow the log without bound. Inspect them in GitHub → Webhooks → Recent Deliveries instead; only authenticated requests reach the log.
Notes and caveats
- Blocking deploy. The listener runs
update.shsynchronously, so a very slow deploy can exceed GitHub's ~10s webhook timeout. The deploy still finishes server-side; GitHub just records a timeout and can redeliver. Background the command in the listener if this bites. - The deployed commit is visible on the site, two ways. Every strategy writes
version.txt(short hash + ISO commit date) into the published tree, for thecurlcheck above. Strategy (B) additionally passes the short SHA torepo-web-view --footer-note, so every generated page's footer carries it, linked to that commit on GitHub — the plain copy in (A) generates no page to stamp, and the footer falls back to unstamped ifgitorREPO_FULL_NAMEis unavailable. - Both strategies publish into
$PUBLISH_DIR. Each sets it —html/for (A), which copies in place, orhtml-new/for (B), which stages and then renames — and the shared steps at the end ofdeploy.sh(the version stamp, then the swap, which is skipped whenPUBLISH_DIRis alreadyhtml/) work off that one variable. Set it if you write your own strategy. - Non-atomic publish.
deploy.shclearshtml/before repopulating it, so the site is briefly incomplete mid-deploy. For zero-downtime you could build into a new directory and swap a symlink (withFollowSymLinksenabled), sincerenameat2(RENAME_EXCHANGE)is not exposed to userspace. - Self-update is a rename, not a copy.
deploy.shinstallsupdate.shand the listener by writing a.newfile beside the target and renaming over it. This matters becauseupdate.shis still executing at that moment and bash reads a script lazily by byte offset: overwriting it in place makes bash resume at that offset in the new file and die with a syntax error on the way out (only when the file's content actually changes, which is what makes it easy to miss). Keep the rename if you rewrite that section. - Trust. Anyone who can push to the branch can run arbitrary shell on the server via
deploy.sh— that is the whole point, but scope the deploy key and secret accordingly.