Deploying a Self-Hosted Static Website with ‘git push’

Posted:
Tags:

It’s something of a trope that when a programmer starts a blog, an inordinate number of their blog posts will be about the act of blogging, especially with regard to their chosen tech stack.

It’s also something of a trope that said programmer/blogger is doomed perpetually to tinker with said tech stack, rather than spending that energy on more directly productive activities; like, I don’t know, writing.

Alas, I am immune from neither of these; and so we find ourselves here, as I prepare to regale you with the tale of how I migrated my blog from Netlify’s fancy specialized static-site hosting to my own general-purpose Ubuntu VM on Digital Ocean.

Well… there’s really not that much of a tale. Towards the beginning of this year, I decided I wanted to try hosting Git repositories on my own domain; and, ultimately, I did so via Digital Ocean. Originally, I intended to leave this website where it was, figuring it would be better for the sake of robustness if my Git server and my blog remained separate.

Then, in February, I came across a viral news story about someone on Netlify’s free tier—which I was also on—who suddenly and unexpectedly received a bill for over $100,000 USD. The bill was ultimately dropped, but the incident nonetheless gave me cause to reconsider my hosting arrangements.

Netlify’s free tier has a usage limit of 100 GB per month, which seems pretty reasonable—I never came close to breaking one gigabyte, let alone one hundred. If you ever do reach the limit, though, that’s when things get hairy: rather than cutting off the site, as I and many others might assume, they instead bill you $55; and from there, another $55 for every additional 100 GB of bandwidth used until the next month begins.

Meanwhile, my Digital Ocean VM costs $6 per month and allows 1,000 GB of bandwidth before imposing overage charges of $0.01 per gigabyte. I would have to use 5,900 GB of bandwidth in one month for Digital Ocean to charge me—including the $6!—as much as Netlify would for 101 GB. Since I was already paying for the VM, hosting my website there too is effectively free and gives me much less cause for anxiety over hypothetical spikes in usage.

There were three main reasons why I chose Netlify in the first place:

  1. It was free. As I just mentioned, though, this is irrelevant since I would have been paying for the VM regardless.
  2. They support the static-site generator Hugo, which I no longer use.
  3. They automatically re-build and re-deploy your site whenever you push commits to GitHub (or GitLab, or a handful of other repository hosts).

This last point was the only one left that I particularly cared about. I had already set up the capability to git push to my server; how hard could it be to make that trigger a deployment as well?

As it turns out, it’s not all that difficult. Still, that didn’t stop me from making a few missteps along the way.

How it works

The repository for my blog is hosted privately, so I can commit drafts in a branch without being too self-conscious about my raw unpolished prose attracting scrutiny. Whereas my public repositories live in the filesystem under /srv/git, my private ones can be found at /home/daniel/private. I also have a symlink /home/daniel/public that points to /srv/git, which allows me to configure my remote URLs as e.g. git.rdnlsmith.com:public/fitbit-cpu-clock.git or git.rdnlsmith.com:private/blog.git. I find this very satisfying.

Meanwhile, the web-server-accessible files for my cgit installation—and, now, my blog—are under /var/www:

daniel@git.rdnlsmith.com:~$ ls -lh /var/www
total 12K
drwxr-xr-x 6 daniel daniel 4.0K Oct  6 20:46 blog
drwxr-xr-x 2 root   root   4.0K Mar 29  2024 cgit
drwxr-xr-x 2 root   root   4.0K Mar 25  2024 html

(The html directory contains the default “Welcome to nginx!” page.)

The contents of /var/www are typically owned by root, so regular users can’t accidentally mess them up. When I push commits, though, my SSH key authenticates me as the user daniel, and any actions performed resulting from e.g. a Git hook will therefore be done as daniel. Consequently, daniel must at least have write access to blog; and with it being my personal website and all, I decided it would be simplest just to make daniel the owner.

As is usually the case on the server side, /home/daniel/private/blog.git is a bare repository: essentially what would be the contents of the .git subdirectory in a normal repository, with no working tree whatsoever.

At first, I tried to simply add a working tree, corresponding to the main branch, under /var/www/blog:

cd /var/www
sudo mkdir blog
sudo chown daniel:daniel blog
cd /home/daniel/private/blog.git
git worktree add /var/www/blog main

This did work, more or less, along with a post-receive Git hook that would git reset --hard to update the files to match whatever commits were just pushed. However, I found that this process left the index in a weird state; and while I suppose that doesn’t really matter as long as the files come out right, I didn’t want to leave it at that.

I removed the worktree, and instead created a self-contained local clone:

git worktree remove /var/www/blog
cd /var/www/blog
git clone /home/daniel/private/blog.git ./

I had tried to avoid this with the worktree route, because I didn’t want to have a second copy of the .git objects/metadata/etc. on the same machine. It turns out, though, that local clones create hard links to the parent repository for most of those files instead of copying them, so it doesn’t particularly waste any more space this way.

The post-receive hook is a file named post-receive in /home/daniel/private/blog.git/hooks. The final version ended up looking like this:

#!/bin/sh

TARGET=/var/www/blog

while read old new ref
do
    if [ "$ref" = "refs/heads/main" ]; then
        echo "deploying $(git show --no-patch --format=reference $new)..."
        git --git-dir="$TARGET/.git" --work-tree="$TARGET" fetch \
        && git --git-dir="$TARGET/.git" --work-tree="$TARGET" reset --hard origin/main
    fi
done

(Don’t forget to make this executable: chmod +x post-receive.)

When Git invokes this script, it pipes in one line to standard input for each ref (branch, tag, etc.) that is getting updated. Each line lists the commit hash that the ref used to point to; the commit hash that it points to now; and the ref name. The while loop above reads each line into three variables: old, new, and ref.

If one of the refs being updated is the main branch, then the script tells the /var/www/blog copy of the repository to fetch the new commits and reset itself to match the updated main ref. It wouldn’t actually hurt anything to do this unconditionally, because /var/www/blog always reflects the main branch no matter what I push; but it would be extra work to make it go through the fetch and reset for no reason when I push some other branch.

The --git-dir and --work-tree options are needed to override the GIT_DIR and GIT_WORK_TREE environment variables, which in this case would reflect the /home/daniel/private/blog.git copy of the repository from which the hook was triggered instead of the /var/www/blog copy where the work needs to happen.

The echo line sends a message back down to my local terminal to let me know which commit is being deployed:

remote: deploying 3b32c43 (Test Mastodon author attribution, 2024-09-21)...

Finally, I added a fairly simple Nginx configuration file named /etc/nginx/sites-available/blog, symlinked it under /etc/nginx/sites-enabled/, and re-loaded Nginx. The first few lines indicate that this file applies to requests with either of the domains rdnlsmith.com or www.rdnlsmith.com, and that the files to serve are located in /var/www/blog/src (as the repository contains more than just the web pages, and as my site is currently handwritten source files with no build step). The rest of the configuration was put there by Certbot to enable HTTPS; this is pretty similar to what it did for cgit, so you can read that post if you want more details.

server {
    server_name rdnlsmith.com www.rdnlsmith.com;
    root /var/www/blog/src;

    listen [::]:443 ssl; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/git.rdnlsmith.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/git.rdnlsmith.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

server {
    if ($host = www.rdnlsmith.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    if ($host = rdnlsmith.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    listen [::]:80;

    server_name rdnlsmith.com www.rdnlsmith.com;
    return 404; # managed by Certbot
}

Some words of warning about DNS

Aside from hosting my blog, Netlify was also my DNS provider; this allowed them to manage the TLS certificates to enable HTTPS for rdnlsmith.com and www.rdnlsmith.com. As I set up my Git server and my Fastmail address earlier this year, Netlify became responsible for those DNS records too. Before I could close out my Netlify account, I needed to move those records to a different provider.

My domain registrar, Namecheap, offers free DNS, so I went with that. Unfortunately, I made the mistake of assuming that I could just copy each entry verbatim from Netlify to Namecheap. This proved not to be the case: while Netlify accepted e.g. git.rdnlsmith.com to specify a subdomain, Namecheap expected just git for the same record, and @ for the record that specified the rdnlsmith.com base domain itself.

When I deleted my Netlify DNS records, my website immediately became inaccessible. I had read somewhere that after transferring DNS, it could take up to 24 hours for downstream DNS resolvers to become aware of the new source; so at first, I chided myself for being too zealous in deleting the old records, and settled in to wait. It wasn’t until the next day, with my website still down, that I finally decided to actually verify that I had set up the new DNS records correctly—which, of course, I hadn’t.

The moral of this story is: when you transfer DNS—or any service, really—from one provider to another, read their documentation first.