Why I Moved My Static Sites Off Netlify

I recently migrated all my static sites from managed hosting (Netlify, GitHub Pages) to a single server I run myself. The trigger was a conversation with a friend about how little maintenance their own servers require, plus the fact that I was nearing Netlify’s free-tier bandwidth cap and didn’t like the overage pricing.

This isn’t a novel setup — it’s pretty standard — but there were enough small decisions along the way that I wanted to document them.

The Setup: One Server, Only nginx, Only Static Files

To keep things simple, the server only runs nginx and only serves static sites. I have about 10 static sites, mostly projects for wizard zines. I chose a $5/month DigitalOcean droplet, which handles my current traffic (about 3 requests per second, 100GB of bandwidth per month) without breaking a sweat — it’s currently using about 1% of its CPU. All sites remain behind the same CDN as before.

Clean Builds Without the Mess of My Laptop

The trickiest problem was ensuring every deployment started from a clean Git repository. My local dev hygiene is poor — I often have uncommitted files lying around that shouldn’t go live. I also wanted builds fast: most of my sites should build and deploy in under 10 seconds.

I hacked together a small build system called tinybuild to handle this. It’s essentially a 4-line bash script with extra argument parsing and error checking:

docker build - -t tinybuild < Dockerfile
CONTAINER_ID=$(docker run -v "$PWD":/src -v "./deploy:/artifact" -d -t tinybuild /bin/bash)
docker exec $CONTAINER_ID bash -c "git clone /src /build && cd /build && bash /src/scripts/build.sh"
docker exec $CONTAINER_ID bash -c "mv /build/public/* /artifact"

Those four lines do the following:

  1. Build a Dockerfile containing all the build dependencies.
  2. Clone the repo into /build inside the container, guaranteeing a clean checkout.
  3. Run the build script (/src/scripts/build.sh).
  4. Copy build artifacts into ./deploy locally.

After that, rsync pushes ./deploy to the server. The approach is fast because docker build - doesn’t send any repository state to the Docker daemon (one repo is 1GB), local git clone is quick on an SSD, and most build scripts just run hugo or cat — only the npm builds take around 30 seconds.

A Curious Git Quirk

I tried git clone --depth 1 to speed things up, but got this warning:

warning: --depth is ignored in local clones; use file:// instead.

The cause, I believe, is that local git clones use hard links for objects, which is much faster than copying. With hard links, --depth 1 seemingly doesn’t apply. Using file:// forces a full copy, which is slower.

Bonus Benefits: Speed and Local Builds

A nice side effect: builds and deploys are now faster than they were on Netlify. For jvns.ca, it’s about 7 seconds versus roughly a minute before.

Running builds locally in Docker containers also works well for me. I’m the only developer on all these sites, my machine is fast, and source files are already on disk — no large downloads. Containers provide isolation without the overhead of a remote CI service.

Example Build Scripts for This Blog

Here are the build scripts used for jvns.ca.

Dockerfile

FROM ubuntu:20.04

RUN apt-get update && apt-get install -y git
RUN apt-get install -y wget python2
RUN wget https://github.com/gohugoio/hugo/releases/download/v0.40.1/hugo_0.40.1_Linux-64bit.tar.gz
RUN wget https://github.com/sass/dart-sass/releases/download/1.49.0/dart-sass-1.49.0-linux-x64.tar.gz
RUN tar -xf dart-sass-1.49.0-linux-x64.tar.gz
RUN tar -xf hugo_0.40.1_Linux-64bit.tar.gz
RUN mv hugo /usr/bin/hugo
RUN mv dart-sass/sass /usr/bin/sass

build-docker.sh:

set -eu
scripts/parse_titles.py
sass sass/:static/stylesheets/
hugo

deploy.sh:

set -eu
tinybuild -s scripts/build-docker.sh \
          -l "$PWD/deploy" \
          -c /build/public

rsync-showdiff ./deploy/ root@staticsites:/var/www/jvns.ca
rm -rf ./deploy

rsync: Showing Only What Changed

When I first used rsync, it listed every file instead of just the changed ones. That’s because each build generates new files with timestamps newer than those on the server. After some searching, I found an incantation to display only updated files:

rsync -avc --out-format='%n' "$@" | grep --line-buffered -v '/$'

I put this into a script called rsync-showdiff so I can reuse it. There may be a cleaner approach, but this works.

Server Setup with Ansible

Configuring the server was straightforward, but I wanted configuration management. At work I’ve used Puppet extensively and don’t particularly like it, so I chose Ansible despite never having used it. It looks simpler, and I avoided plugins to maximize the chances I can still run this setup in three years. You can see my current Ansible configuration, minus some templates.

The most involved part is the reload nginx handler, which validates the nginx configuration before reloading.

Replacing a Netlify Lambda Function

One function needed replacing: a Netlify lambda that calculated purchasing power parity (PPP) discounts for wizardzines.com. It geolocates the visitor by IP and returns a discount code for applicable countries (70% off for India, for instance).

I rewrote the small program in Go, copied the static binary to the server, and added a proxy_pass for that site. The logic is no more than looking up a country code from the Cloudflare geolocation HTTP header in a hashmap, so maintenance should be minimal.

A Minimal nginx Configuration

Most of my sites share the same nginx config template:

server {
	listen 80;
	listen [::]:80;

	root /var/www/{{item.dir}};
	index index.html index.htm;
	server_name {{item.server}};

    location / {
        # First attempt to serve request as file, then
        # as directory, then fall back to displaying a 404.
        try_files $uri $uri/ =404;
    }
}

The {{item.dir}} is an Ansible interpolation. I also added custom 404 pages via error_page /404.html in the main nginx.conf.

TLS between the CDN and origin isn’t set up yet — I’ll add certbot later. My CDN currently handles TLS to the client only. I’m not sure if such a minimal nginx config will bite me later; time will tell.

Seeing All 404s

One unexpected upside of self-hosting is better visibility into site issues. I ran grep 404 /var/log/nginx/access.log and discovered many links broken for years that I’d never noticed. Netlify’s analytics shows the top “resources not found,” but not every 404.

Cost Considerations

Costs were a factor. Netlify’s free tier caps bandwidth at 100GB/month, with $20 per additional 100GB. DigitalOcean charges $1 per 100GB of additional bandwidth (20x less), and the droplet includes 1TB per month. That pricing model feels far more reasonable to me.

Initial Verdict

All my static sites now run on my own server. I don’t yet know what long-term maintenance will involve, but I already value the faster builds and easy log access. It might not stay this pleasant, but the early results are good.