Ok so the new server from the last chapter is running great. No more crashes, no more phantom shutdowns, no more ghost battery killing the system at 2 AM. But there was still one thing that annoyed me: every time I wanted to update my website, I had to SSH into the server and manually copy files around. Edit something on my laptop, scp it to the server, fix permissions, reload Nginx... it was getting old fast.

So I thought: what if the server could update itself every time I push to GitHub?

"The laziest solution is often the most reliable one."

The Idea

The concept is simple and it's called CI/CD (Continuous Integration / Continuous Deployment)... fancy words for "automate the boring stuff":

  1. My website files live in a private GitHub repo
  2. I set up a small webhook listener on the server
  3. When I git push, GitHub sends a POST request to my server
  4. The server receives it, verifies it's legit, and runs git pull
  5. Website updated. No SSH. No SCP. No manual anything.

Sounds easy right? Well... it mostly was, but of course there were some fun problems along the way ahahah.

Step 1: Deploy Keys

Since my repo is private, the server needs a way to authenticate with GitHub. I didn't want to put my personal GitHub credentials on the server (that would be a security nightmare), so I used a deploy key โ€” an SSH key that only has read access to one specific repo.

sudo ssh-keygen -t ed25519 -f /root/.ssh/github-deploy -N ""
sudo cat /root/.ssh/github-deploy.pub

Copied the public key to GitHub โ†’ repo Settings โ†’ Deploy keys. Read-only access, no write permissions needed. The server can pull but can never push โ€” exactly what I want.

Step 2: Clone the Repo

Initialized git in my web root and pulled everything down:

cd /var/www/mosearc.eu
sudo git init
sudo git remote add origin [email protected]:[USERNAME]/[REPO].git
sudo git config --local core.sshCommand "ssh -i /root/.ssh/github-deploy"
sudo git fetch
sudo git reset --hard origin/main

That core.sshCommand line is important โ€” it tells git to use the deploy key specifically for this repo, instead of looking for a default SSH key (which doesn't exist on the server).

Step 3: The Deploy Script

Created a simple bash script at /usr/local/bin/deploy-website.sh:

#!/bin/bash
cd /var/www/mosearc.eu
git pull origin main
chown -R www-data:www-data /var/www/mosearc.eu/
logger "deploy-website: pulled latest from GitHub"

It pulls the latest code, fixes file ownership for Nginx, and logs the event. Nothing fancy, but that's the point โ€” less code means less things that can break.

Step 4: The Webhook Listener

Installed webhook, a lightweight Go tool that listens for HTTP requests and runs scripts when triggered:

sudo apt install webhook

Created the config at /etc/webhook.conf:

[
  {
    "id": "deploy",
    "execute-command": "/usr/local/bin/deploy-website.sh",
    "command-working-directory": "/var/www/mosearc.eu",
    "trigger-rule": {
      "match": {
        "type": "payload-hmac-sha256",
        "secret": "[WEBHOOK_SECRET]",
        "parameter": {
          "source": "header",
          "name": "X-Hub-Signature-256"
        }
      }
    }
  }
]

The trigger-rule part is the security โ€” GitHub signs every webhook request with an HMAC-SHA256 hash using a shared secret. The listener verifies the signature before executing anything. So even if someone discovers the endpoint URL, they can't trigger a deploy without knowing the secret.

Created a systemd service so it starts automatically on boot:

[Unit]
Description=GitHub Webhook Listener
After=network.target

[Service]
ExecStart=/usr/bin/webhook -hooks /etc/webhook.conf -port 9000 -verbose
Restart=always

[Install]
WantedBy=multi-user.target

Step 5: Nginx Proxy

The webhook listener runs on port 9000 internally, but I don't want to expose random ports to the internet. So I added a reverse proxy in my Nginx config to route /hooks/ to the listener:

location /hooks/ {
    proxy_pass http://127.0.0.1:9000/hooks/;
}

This way GitHub sends requests to https://mosearc.eu/hooks/deploy, Nginx handles TLS and forwards it internally to the webhook listener. Clean.

The Debugging Adventure

Of course it didn't work on the first try. GitHub showed "Last delivery was not successful. Invalid HTTP Response: 404." Classic.

Problem 1: Wrong Server Block

I accidentally put the location /hooks/ block in the port 80 server block (the one that redirects HTTP to HTTPS). But GitHub sends webhooks to https://, which hits the port 443 block โ€” where there was no hooks location. And even if it had reached the port 80 block, there was a return 404 before the location directive, so it would never have worked anyway.

Moved it to the HTTPS server block. Fixed.

Problem 2: Git Dubious Ownership

When the webhook ran the deploy script, git refused to pull:

fatal: detected dubious ownership in repository at '/var/www/mosearc.eu'

This is a git security feature โ€” it warns you when the repo is owned by a different user than the one running the command. Since the files are owned by www-data but the script runs as root, git got suspicious. Fixed with:

sudo git config --global --add safe.directory /var/www/mosearc.eu

Problem 3: Deploy Key Not Being Used

Even after fixing the ownership issue, git couldn't connect to GitHub:

[email protected]: Permission denied (publickey).

The core.sshCommand config that tells git to use the deploy key wasn't set. One command fixed it:

sudo git config --local core.sshCommand "ssh -i /root/.ssh/github-deploy"

The Result

After all the debugging, it works perfectly. My workflow now is:

  1. Edit website files on my laptop
  2. git add . && git commit -m "update" && git push
  3. Wait 2 seconds
  4. Refresh the browser โ€” changes are live

No SSH. No SCP. No manual file copying. No permission fixing. Just push and it's done. I can even update the website from my phone if I commit through GitHub's mobile app.

This is actually how "real" deployment works in professional environments (well, a simplified version โ€” they usually have build steps, tests, staging environments, and rollback mechanisms). But for a static portfolio site on a home server? This is more than enough.

Monitoring

I can check if deploys are working anytime:

# Check webhook service
sudo systemctl status webhook

# Check deploy logs
sudo journalctl -u webhook -f

# Check system log for deploy events
grep "deploy-website" /var/log/syslog

And on GitHub โ†’ repo Settings โ†’ Webhooks, I can see the delivery history with timestamps and response codes. If a delivery fails, GitHub even lets me redeliver with one click.

The Elephant in the Room: Root Access

Ok so this whole thing works, but there's something that bothers me about it. The deploy script runs as root. The webhook service runs as root. The git pull runs as root. Everything runs as root.

Why does that matter? Because the chain looks like this:

  1. GitHub sends a request to my server
  2. The webhook listener (running as root) receives it
  3. If the HMAC signature matches, it executes a bash script (as root)
  4. That script runs git pull (as root) and chown (needs root)

So basically, there's a service exposed to the internet that can execute scripts with full system privileges. Yes, it's protected by the HMAC secret, and yes, you'd need to know the secret to trigger it. But if somehow the webhook secret leaked, or if there was a vulnerability in the webhook binary itself, an attacker could potentially execute commands as root on my server.

I actually tried to fix this. The idea was to run everything as www-data instead of root โ€” move the deploy key, change file ownership, run the webhook service as a non-privileged user. But I ran into a cascade of permission errors. Git couldn't write its config, SSH couldn't read the key, the whole thing broke in multiple places at once. Classic case of "fixing" one thing and breaking three others.

So I rolled it all back and kept the root approach. For now.

Why I'm (Mostly) OK With It

Is it ideal? No. But here's what makes me sleep at night:

  • The webhook secret is a 40-character random hex string โ€” not guessable
  • The endpoint is behind HTTPS, so the secret can't be sniffed in transit
  • The script only does git pull and chown โ€” it's not executing arbitrary input
  • The webhook binary is from the Debian repos, so it gets security updates
  • This is a home server hosting a portfolio, not a bank

What I Want to Do Eventually

The proper solution would be to:

  • Create a dedicated deploy user with minimal permissions
  • Give that user ownership of /var/www/mosearc.eu/
  • Store the deploy key under that user's home directory
  • Run the webhook service as that user
  • Use sudoers to allow only the specific chown command if needed

That way, even in the worst case scenario, an attacker would only have access to the website files โ€” not the entire system. It's called the principle of least privilege and it's basically the golden rule of security: give every process only the permissions it absolutely needs, nothing more.

But for today, the root approach works, the webhook is secured with HMAC, and I have bigger things on my to-do list. Sometimes "good enough and working" beats "perfect and never finished." I'll revisit this when I containerize everything with Docker โ€” at that point the deploy process will be isolated anyway.


What's Next

The server is getting more and more "professional" with each chapter. The to-do list from before is still alive:

  • Containerization with Docker
  • UPS (because power outages are still a thing)
  • Notification system (Telegram bot for server alerts)
  • Self-hosted drive with NAS
  • Mail server
  • Local LLM (still probably can't, but still want to try)

But honestly? Right now the server is in the best state it's ever been. Stable OS, proper security, automatic deploys. It actually feels like infrastructure instead of a science experiment. Progress!


Update: I Couldn't Resist

Ok so remember when I said "I'll revisit this when I containerize everything"? Yeah, that lasted about 15 minutes. The thought of a root-level webhook kept nagging at me. I just couldn't leave it like that knowing there was a better way. So I fixed it. Right away. Same day.

What I Did

Created a dedicated deploy user with the absolute minimum permissions needed to do one job: pull code from GitHub into the website directory.

sudo useradd -r -s /usr/sbin/nologin -d /var/lib/deploy deploy
sudo mkdir -p /var/lib/deploy/.ssh

The -r flag makes it a system user (no home directory bloat, no login capabilities), and -s /usr/sbin/nologin means nobody can ever SSH in as this user or get an interactive shell. It exists for one purpose only.

Moved the GitHub deploy key from root's SSH directory to the new user:

sudo cp /root/.ssh/github-deploy /var/lib/deploy/.ssh/
sudo chown -R deploy:deploy /var/lib/deploy
sudo chmod 700 /var/lib/deploy/.ssh
sudo chmod 600 /var/lib/deploy/.ssh/github-deploy

Changed website ownership so deploy owns the files and www-data (Nginx) can read them through the group:

sudo chown -R deploy:www-data /var/www/mosearc.eu/

Updated the git config to use the new key location:

sudo -u deploy git config --global --add safe.directory /var/www/mosearc.eu
cd /var/www/mosearc.eu
sudo git config --local core.sshCommand "ssh -i /var/lib/deploy/.ssh/github-deploy"

Simplified the deploy script โ€” no more chown needed since deploy already owns everything:

#!/bin/bash
cd /var/www/mosearc.eu
git pull origin main
logger "deploy-website: pulled latest from GitHub"

Updated the webhook service to run as deploy instead of root:

[Service]
User=deploy
ExecStart=/usr/bin/webhook -hooks /etc/webhook.conf -port 9000 -verbose
Restart=always

And locked down the webhook config file so only root can edit it and deploy can read it:

sudo chown root:deploy /etc/webhook.conf
sudo chmod 640 /etc/webhook.conf

Why This Is Better

The difference is what happens in the worst case. With the old setup, if someone compromised the webhook they'd have root access โ€” game over, entire server owned. With the new setup, even if the webhook is somehow exploited, the attacker is trapped inside the deploy user's sandbox:

  • Can't read system configs, can't modify Nginx or OpenVPN or SSH
  • Can't access /root/, can't read other users' files
  • Can't install packages, can't create users, can't open ports
  • Can't even get a shell โ€” nologin blocks it
  • Can only touch website files โ€” the worst they could do is deface my portfolio

It's the principle of least privilege: every process gets only the permissions it absolutely needs, nothing more. The blast radius of a breach goes from "entire server" to "just the website." I can live with that.

Lesson learned: if something is bothering you about your setup, just fix it now. "I'll do it later" is a lie we tell ourselves, and the 20 minutes it took to do this properly was absolutely worth the peace of mind.