You (Probably) Don't Need Netlify for Your Static Site

Today's development landscape is so littered with great static site generator options that it seems senseless to use a dynamic app like WordPress to do a simple blog or site if you are a technically-proficient user or developer. There are many articles for your static site generator of choice explaining how to create a site with continuous deployment, most using Netlify, which is a PAAS for static websites. Netlify is a great platform for many commercial and team applications, but you sure don't need it for your personal sites. This article details one path to making this happen. The principles will be very similar no matter what technologies you choose to use, so simply pay attention to the tasks we are tackling and adjust your workflow for your own needs.

Tooling for this tutorial

Step 1. Get a static site.

I enjoy using Eleventy as I find it to be the simplest and most flexible of all the static site generators. Other good choices are Hugo and Jekyll. Or you might even forgo tooling in the simplest cases such as landing pages and simply code the site by hand. It's your choice. If you don't already have a site, there's an Eleventy Test Blog that you can download from Github for this tutorial.

Step 2. Get a server.

Generally I use Ubuntu for servers as I'm used to the Debian system but enjoy the convenient preinstalled tools that ship with Ubuntu by default. I use DigitalOcean, you may use something like AWS (don't, Amazon are anti-labor scumbags), Linode, or Docker. It doesn't really matter. You can use almost any Unix-like, but I'm only going to show the process with Ubuntu and systemd. If you do want to use DigitalOcean or Linode, just get the cheapest base server, which is $5 USD a month. You don't need much box to serve static sites. Update and upgrade the server with the command apt update && apt dist-upgrade -y.

Step 3. Set up the web server.

We're going to use the webserver I use for almost everything now, Caddy. Caddy is a fast, easy, modern server written in Go that is especially suited to simple or containerized deployments. I think you'll see why by the end of this tutorial, but some key features are default HTTP/2, automatic SSL, configuration via file or API, ability to render code on the fly, and extreme simplicity.

Caddy Step 1. Download and install.

Caddy, being a Go app, ships static binaries that you can simply download and throw in your path as is the custom with most Go apps. Compiled releases are available at current newest is 2.0.0-rc.3 but check before you download.

SSH into your server as root and run the following command:


Extract this archive with the command tar zxf caddy_2.0.0-rc.3_linux_amd64.tar.gz. Move it into your system path with the command mv caddy /usr/bin.

Caddy is now ready to be run on the system. Check it by typing caddy version if it pleases you.

We're also going to apt install libnss3-tools as Caddy needs those libs to utilize Let's Encrypt.

Caddy Step 2. Configure the daemon.

Next you'll need to create a nologin account for the webserver to use. We'll additionally use the caddy group to deploy code. Use the following command:

useradd --system \
--gid caddy \
--create-home \
--home-dir /var/lib/caddy \
--shell /usr/sbin/nologin \
--comment "Caddy web server" \

Now you'll need to configure the server to run as a service with systemd. Caddy's repo has two available in the /dist/init folder, depending on the configuration type you wish to use. We'll be using Caddyfile, so we'll pick caddy.service. The default config works well for Ubuntu, so we only need to copy and make a small edit. The following is a quick way to get it in and running:

wget -O /etc/systemd/system/caddy.service
systemctl enable caddy
systemctl start caddy

We'll only need to make one small edit to the file to allow the server to start automatically. Under the [Service] section, add the line Restart=on-abnormal. This will make sure the service starts if it's not running for some reason such as server reboot or crash. You can reload the systemd service with the command systemctl reload caddy. Check the status of this service anytime with systemctl status caddy.

Caddy Step 3. Give it a directory to serve from.

Generally Ubuntu uses /var/www as it's web directory. Create this. Then create a directory inside this directory with the domain you want to serve up. I'm going to just refer to mine in the tutorial, Replace it with the one you're using in your terminal. I make the directory and then set the ownership using the following two lines:

chown caddy:caddy
chmod 770
chmod g+s

This sets the serving directory to be owned by the caddy group, which we will also use for deployments later. The group should be allowed to write files to this directory as well. The last command makes sure the caddy group always has ownership of files written here.

There are ways to make this a little more secure, but the question is what is the actual risk here? I am only using the server to serve up static files in this case. I'll go over a suggestion for that in the build step. If you want to go an extra step, there's a simple firewall called ufw that ships with Ubuntu that you can set up easily. Generally I don't bother with this as DigitalOcean comes with a cloud firewall which is much easier to use. We could now go set up the server, but instead of doing a "hello world," let's just serve up our actual site.

Step 4. Create a deployment account.

Since the caddy user account is not a login account for security purposes, we'll need to create an account that is able to login and get files on the server. Here's an example of how to create one.

useradd --create-home \
--groups caddy \
--shell /bin/bash \

Let's now set up keys for this account by running su deploy to login. Generate a new set of SSH keys on the account with ssh-keygen. Hit enter three times to accept the defaults and ignore setting a password. If you don't know how to add public keys to the authorized_keys file, have a look at this article. Once that's done, you'll also need the text from the private key for the next step. Print it out with less ~/.ssh/id_rsa.

Step 5. Get the build working.

Now it's build time. I use Sourcehut for my git hosting which comes with a build service. I believe Gitlab has something similar. Sourcehut's is very simple to use, so I highly recommend it. The first thing you'll need to add that private key we mentioned at the end of the previous step to your secrets storage. Go to the builds tab and select the button on the left that says "Manage secrets." Paste the copy of the private key in the "secret" and give the key a memorable name. Select "SSH Key" as the secret type and click "Add secret." You'll then be given a UUID you will use in the build file to securely use the private key to login to the server.

Back at the builds tab, you can click the "Submit manifest" button. This will take you into a web editor to test your build YAML. Attached is a sample YAML file I use for deployments for the site you are looking at.

image: ubuntu/lts
- curl
deploy: [email protected]
- 467f9b86-c424-469e-8908-d2820edce70f
- setup: |
curl -sL | sudo -E bash -
sudo apt install -y nodejs
cd paulg-11ty
npm install

- build: |
cd paulg-11ty
npm run-script build

- deploy: |
scp -o StrictHostKeyChecking=no -r paulg-11ty/dist $deploy:/var/www/

Let's look at each entry in this file. First, the image. I'm using Ubuntu LTS but in this case it doesn't matter at all. Second, additional packages you need to install on the image to run the build. Next, the source repo we weill fetch. My repo is public, so I simply use the read-only web address. The following section can be somewhat complex in some builds, the environment dictionary. These are simply key-value pairs we can reference elsewhere in tasks. I only have one here. Next is the secrets file, This is simply the private key we set up in step 5 to when run scp. Next is the actual tasks. They are split up mostly to make it easier to comb through the build logs later. It makes troubleshooting easier if you can see immediately which part of the build failed. Here, I'm installing Node LTS and npm installing the app. In the build step, I'm running that actual npm script that compiles the Eleventy site and finally I'm copying the build.

Once the manually submitted manifest is working, paste the manifest into a .build.yml file in your project root and it will automatically run on every push to Sourcehut. You got CI/CD baby! Who needs a CMS?

Step 6. Serve up the site.

You have a chance to do some hardening here, maybe set up a cron job to change the permissions on the files copied to your server prior to deployment, but since these are just static files, it's probably not worth it. We're just going to serve them straight out. I know you're probably like "ugh, here's the part where I have to learn complex configuration for a brand-new web server." Yes. Here's my entire server config: {
encode zstd gzip
root * /var/www/

Wait, where's all the stuff for SSL and HTTP/2 and what not? It's all built in. First line, turn on compression. Second line establishes the base directory for this domain (it's the build directory from step 5). Third line says serve everything as files. This config lives at /etc/caddy/Caddyfile as defined in the caddy.service definition a while back. It feels like magic, but that's Caddy for you.

And that's it! I hope you can grok all the steps on here if you know a little about servers. You should also be able to see how you can alter the steps to meet your own deployment needs. If you have any questions or corrections, please submit a ticket at Thanks!