How push-to-deploy works under the hood
Connect a repo, push a commit, watch a deploy start. It's such a well-worn interaction that we've stopped thinking of it as a feature. Then one day the push lands and nothing happens, and it turns out nobody on the team knows what the mechanism even is. So you push an empty commit. Then another. Then you start reading logs.
This is what's actually behind that button. I build PandaStack, which implements it, but the pieces are the same everywhere — Vercel, Netlify, Render, Railway, and your colleague's homegrown script all do these five things, better or worse.
Step one: the connection is an installation, not a login
The modern pattern is a GitHub App installed on an account or organisation, which is meaningfully different from an OAuth login. An OAuth token acts as you, carries your permissions everywhere, and dies when you leave the company — which is how a deploy pipeline breaks two weeks after someone's last day. An App installation belongs to the organisation, is scoped to the repositories you select, and survives staff changes.
The flow is: the platform sends you to GitHub's install page with a state parameter for CSRF protection, you pick repositories, GitHub redirects back with an installation id, and the platform records that installation against your workspace. From then on it can list your repos and mint credentials for them.
Step two: the webhook, and proving it's real
When you push, GitHub POSTs a push event to the platform's webhook endpoint. That endpoint is public — anyone can send it a JSON body claiming a push happened to your repository. So the first thing it does is verify an HMAC signature over the raw request body using a shared secret.
Two details people get wrong. Verify against the raw bytes, not a re-serialised parse of the JSON, because whitespace and key ordering change the hash. And compare in constant time, since a naive string comparison leaks the signature one byte at a time to anyone patient enough to measure.
import hashlib
import hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a GitHub webhook signature.
raw_body MUST be the exact bytes received. Parsing to JSON and
re-serialising changes whitespace and key order, and the hash with it.
"""
if not header or not header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
# constant time: a plain == leaks the signature byte by byte via timing
return hmac.compare_digest(expected, header)If verification fails you return a 4xx and log it. If it passes, you have a trustworthy claim that a specific commit landed on a specific ref of a specific repository.
Step three: deciding whether this push is yours
A push event arrives for a repository, but many apps may be connected to that repository, watching different branches. So the platform looks up every app matching the repo, filters to those with auto-deploy enabled, and filters again to those whose configured branch matches the pushed ref.
This is where most 'my push didn't deploy' tickets end up, and the causes are mundane: the app watches `master` and you pushed `main`; the push was a tag or a branch deletion rather than a commit; auto-deploy is off; the repository was renamed and the stored URL no longer matches; or the App installation lost access to that repo when someone tightened the repository selection.
Before you push another empty commit, check the delivery. GitHub shows every webhook delivery, its payload, and the response your platform returned, under the App or repository settings. A 200 with no deploy means matching failed on the platform's side. A 401 means signature verification failed — usually a secret rotated on one side only. No delivery at all means the event was never subscribed to.
Step four: cloning a private repo without storing a key
Now the platform needs read access to your code. The bad old approaches were a deploy key it stored forever, or a personal access token belonging to whoever set it up — both of which are long-lived secrets sitting in a database waiting to be exfiltrated.
The App pattern is better: the platform signs a short-lived JWT with its private key, exchanges it for an installation access token scoped to your selected repositories, uses that token for the clone, and never persists it. The token expires in about an hour regardless. So a database compromise yields no repository access, and revoking the installation revokes everything instantly — no hunting for keys to rotate.
The clone itself should be shallow and pinned to the exact commit from the webhook, not to the branch head. Between the push and the clone, someone may have pushed again; resolving the branch at clone time means your deployment record says one commit and your running code is another, and you will not enjoy debugging that.
Step five: the races
Everything so far is a straight line. Reality is concurrent, and three races matter.
- Rapid pushes. Merge three PRs in a minute and three deploys start. Without a guard they finish out of order and the live version is whichever happened to be slowest, not the newest. You need either a queue per app, or a rule that a newer deploy supersedes and cancels an in-flight older one.
- Duplicate deliveries. Webhooks are at-least-once. GitHub retries on timeout or a 5xx, so a slow endpoint can produce two identical events. Deduplicate on the delivery id, and make sure the endpoint returns quickly — accept the event, queue the work, respond 200. Doing the deploy inline is how you turn a retry into a double deploy.
- The flip. Two deploys reaching the routing flip at once must not interleave. The mapping update needs to be atomic, and the loser must tear down its VM rather than leaving it running, unrouted, and billing.
A debugging order that works
When a push doesn't deploy, walk the chain in order rather than guessing. Each step has a distinct signature.
- Did GitHub send a delivery? No delivery means the App isn't installed on that repo or isn't subscribed to push events.
- What status did the platform return? 401 or 403 means signature verification failed — check for a rotated secret. 5xx means the platform broke and GitHub will retry.
- 200 but no deployment appeared? Matching failed: branch mismatch, auto-deploy disabled, renamed repo, or a tag push rather than a branch push.
- Deployment created but immediately failed? A clone failure means the installation lost access to the repository. Anything later is your build.
- Deployment succeeded but the site is unchanged? Look at the flip and at any CDN or browser cache in front of it — and confirm the commit on the deployment record is the one you expect.
Why this is worth understanding
None of these mechanisms is difficult. What makes them worth knowing is that they fail independently and produce identical symptoms from the user's chair: 'I pushed and nothing happened.' Signature verification, branch matching, installation permissions, and the ordering race have nothing in common except how they look from outside.
Five minutes with the webhook delivery log beats an hour of empty commits. And if you're building this rather than using it: verify signatures over raw bytes in constant time, bind installation ids to the workspace that created them, clone the exact commit rather than the branch, respond to the webhook before doing the work, and make the flip atomic. That's the whole feature, and the security-relevant parts are all in the first two.
Frequently asked questions
Why didn't my git push trigger a deploy?
Walk the chain rather than guessing. First check whether GitHub recorded a webhook delivery at all — if not, the App isn't installed on that repository or isn't subscribed to push events. If there is a delivery, look at the response status: a 401 or 403 means signature verification failed, usually because a webhook secret was rotated on one side only, and a 5xx means the platform errored and GitHub will retry. A 200 with no deployment means matching failed on the platform side: the app watches a different branch, auto-deploy is off, the repository was renamed, or the push was a tag rather than a branch commit.
Why do platforms use a GitHub App instead of an OAuth token?
An OAuth token acts as the person who authorised it, carries their permissions across everything they can access, and stops working when they leave the organisation — which is how deploy pipelines mysteriously break two weeks after someone's last day. A GitHub App installation belongs to the organisation, is scoped to the repositories explicitly selected during install, and survives staff changes. It also enables short-lived installation tokens minted on demand for a clone and never persisted, so a compromise of the platform's database does not yield access to your source code.
How should a webhook signature be verified?
Compute an HMAC over the exact raw request bytes using the shared secret, and compare it to the signature header in constant time. Two mistakes are common and both are real vulnerabilities. Verifying against a re-serialised parse of the JSON body fails or, worse, is worked around by disabling verification, because whitespace and key ordering change the hash. And using a normal string comparison leaks the correct signature one byte at a time through timing differences, which is exactly what constant-time comparison functions exist to prevent.
What causes duplicate or out-of-order deploys?
Webhook delivery is at-least-once: GitHub retries on timeout or a 5xx response, so an endpoint that performs the deploy inline instead of queueing it can produce two deploys from one push. Deduplicate on the delivery id and respond quickly. Out-of-order deploys come from concurrency: merge three pull requests in a minute and three deploys race, and without a per-app queue or a rule that a newer deploy cancels an in-flight older one, the live version ends up being whichever build happened to be slowest rather than the newest commit.
Should a deploy clone the branch or a specific commit?
Always the exact commit carried in the webhook payload. If you resolve the branch at clone time, any push that lands between the event and the clone silently changes what you build, so the deployment record claims one commit while the running code is another — a genuinely miserable thing to debug. Pinning the commit also makes rollback and audit meaningful, since every deployment record maps to a specific tree, and it makes retries idempotent rather than picking up whatever has landed since.
49ms p50 cold start. Fork, snapshot, and scale to zero.