I made my server build and deploy on its own with a single git push
A push to GitHub hits a webhook and the server pulls, installs, builds and copies. Also: signature checks, private repo auth and how deploys quietly stopped after I moved a domain.
#Infrastructure #CICD #GitHub #Webhook
The most annoying part after building my portfolio site was deploying it. After changing a bit of code, I'd connect to the server, run git pull, run npm install, build, and copy the resulting dist into the serving folder. Doing this by hand every time meant that even pushing out a trivial fix became a chore. Some days I just left a typo alone because opening a terminal to fix it was too much bother. So I set up automatic deployment, so that the server does everything on its own as long as I push to GitHub. In this post I'll go through how I put this pipeline together with a Webhook, and also how it later stopped. The overall flow How it works is simple. When I push from local to GitHub, GitHub sends a request to /webhook/deploy on the server. The server takes that request and automatically runs git pull, npm install, the build and the dist copy in order, and emails me the result whether it succeeded or failed. In other words, the only thing I do is git push, and the server takes care of the rest. The endpoint that receives the Webhook When a push happens, GitHub sends a POST to the URL you've configured. On the server, all you need is one endpoint open to receive it. app.post('/webhook/deploy', (req, res) = { const signature = req.headers['x-hub-signature-256']; // verify the webhook secret if (process.env.WEBHOOK_SECRET) { const hmac = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET); const digest = 'sha256=' + hmac.update(JSON.stringify(req.body)).digest('hex'); if (signature !== digest) { return res.status(401).json({ error: 'Invalid signature' }); } } res.json({ message: 'Deploy started' }); deploy(); // the actual deploy runs asynchronously }); What I paid attention to here is signature verification. This endpoint is simply open to the internet, so if anyone at all calls it, they can trigger a deploy whenever they like. So I compare the x-hub-signature-256 header that GitHub sends against a value I compute myself with WEBHOOK_SECRET, to check that the request really came from GitHub. You just put the same value into Secret in the Webhook settings on the GitHub side. And the response goes out as soon as verification is done, while the actual deploy runs separately in the background. The build takes tens of seconds, and making GitHub wait that long causes a timeout. GitHub records a delivery as failed if it doesn't get a response within 10 seconds. The actual deploy logic The deploy function just runs the commands I used to type by hand, in order. It runs them one at a time with execSync. async function deploy() { process.chdir(PORTFOLIO_DIR); // 1. pull the latest code execSync('git pull'); // 2. install dependencies execSync('npm install'); // 3. build execSync('npm run build'); // 4. copy the build output to the serving directory execSync(`cp -r dist/. ${DIST_DIR}/`); await sendMail('Deploy succeeded', output); } I used execSync because the order matters. install must not run before pull finishes, and the copy must not happen before the build finishes. If even one of them fails, an exception is thrown and it doesn't move on to the next step, and that exception leads to the failure email below. Where I got stuck was private repository auth At first I got stuck on git pull. It's a private repository, so a plain pull on the server asks for authentication. In a terminal I can type the password myself, but a process that runs automatically can't do that. So I generated a GitHub Personal Access Token and solved it by inserting the token into the remote URL. const githubToken = process.env.GITHUB_TOKEN; if (githubToken) { const urlWithToken = remoteUrl.replace( /https:\/\/(.*@)?github\.com\//, `https://${githubToken}@github.com/` ); execSync(`git remote set-url origin "${urlWithToken}"`); } I put the token only in .env and didn't hardcode it. If a token like this gets committed along with the code, that alone is a security incident, so credentials should always be pulled out into environment variables. For the token's permissions, read access to the repository is enough. The email notification mattered more than I expected The trap with automatic deployment is that I stop watching the deploy process. When I do it by hand, a build error shows up in the terminal right away, but when it runs automatically, it can fail and I can go on without knowing. So I made it email me the whole log when a deploy finishes, whether it succeeded or failed. With this in place, when I've pushed and I'm wondering why the site hasn't changed, I only have to open my inbox to see right away where it broke. Automation is in the end about making it so a person doesn't have to watch, but for that there has to be something alongside it that tells you when a problem comes up. And right now it isn't running A long while after writing this post, I temporarily pointed the root domain at the blog instead of the portfolio, because of the AdSense review. All I did was change one ProxyPass line in the Apache config from 5015 to 5000. I didn't notice anything for a while after that, until one day I pushed and the site stayed the same. The cause was simple. The address GitHub sends the webhook to was the root domain, and that domain now goes to the blog server. The blog server has no such route, so it's a 404. /webhook/deploy is still alive in the portfolio server's app.js, but the request never gets that far. $ curl -X POST https://mydomain/webhook/deploy 404 $ pm2 logs | grep "Deployment completed" 2026-08-18 22:03 - last successful deploy The automatic deployment had quietly stopped, and no notification came at all. The failure email only goes out once a deploy has started, so when the request never arrives in the first place, there was nothing to send. When I built the automation I only thought about the case where it runs and fails, and I hadn't prepared for the case where it never gets called at all. In mid-September 2026, as I'm revising this post, I ran the same curl again and it's still a 404, and the root domain's ProxyPass is still pointing at 5000. So I'm still building by hand now. I plan to move the webhook address so that it sits directly on the server side and not on the domain. That way, wherever the domain points, deployment isn't affected. If you've set up automatic deployment, I'd recommend also getting into the habit of checking now and then how many days ago the last deploy was.