Ripped straight from my docs.

Lighttpd + Certbot + Rsync for Static Sites

Lighttpd + Certbot + Rsync for Static Sites

I’ve been working on several client projects over the summer involving scenarios where serving static website content, using Hugo, has made for an ideal solution when needing to deliver information.

Debian VMs running Lighttpd using Certbot to manage TLS and Rsync for easy deployments works perfectly for my needs - and may be a solution you’re looking for if you’re reading this.

Instead of a genetic “how to install Lighttpd” tutorial, let’s try something a bit different. I’ll show you exactly what I’m running, why I made a few of the decisions I made, and then give you the documentation to build it. There are no production architecture diagrams hiding anywhere, there’s no Kubernetes cluster involved just for serving a static site; we’re working with a basic Linux VM handling a specific job and that’s it.

What We’re Building

The basic setup is straightforward. Hugo builds the website into static files. Lighttpd serves those files.

That’s it as far as the web server itself is concerned. There’s no PHP or database, there’s no application server waiting around to generate pages dynamically. Hugo does the work of turning content into a website, and Lighttpd does the work of returning that website when someone asks for it.

My broader Homelab environment adds the other pieces I need - network access, TLS, access controls, and other layers that keep an individual service from having to solve every problem on its own. The important thing is that those concerns are separate:

  • Lighttpd doesn’t need to know how the site was built
  • Hugo doesn’t need to know how the site was build
  • Neither needs to have broad access to the rest of my network

This separation makes the whole thing easier to understand, document, and maintain.

Why Lighttpd?

I’ve used a fair number of web servers over the years. Apache running on Slackware is where I got my first look at how content is served, and then I spent years working with IIS and ASP. Later, NGINX and Varnish became part of my world when I was dealing with high-traffic publishing environments. They’re all perfectly reasonable tools for the problems they solve - and my problem is much smaller now.

If I’m serving a Hugo-based site, I mostly need something that can reliably take a request and respond with the contents of a file. Lighttpd does that well, plus it’s small, straightforward, and doesn’t require me to build an entire application stack around a directory full of HTML. That doesn’t make it universally better than Apache or NGINX, it just means that I don’t need the additional capabilities of those platforms for this particular job.

There’s also a security-adjacent benefit to keeping the job small. The service doesn’t need to build the website - it doesn’t need to modify the source content or access unrelated parts of the system. It doesn’t need database credentials, tokens, or other forms of authentication, either.

These aren’t exotic security controls or anything, they’re just the consequences of giving the service fewer things to do - and I really like that.

Here’s the Actual Documentation

This is where I’m going to do something slightly unusual. The following section is essentially a copy and paste of my internal Docmost instance - there are just a few minor tweaks to accommodate some non-standard markup.

I originally wrote this for myself, so some of it is specific to my environment. Some of it assumes you’ve already got a working Linux system and know your way around the command line. If you’re trying to do roughly the same thing, this will serve as a good starting point. Enjoy!


Hugo-based sites are lightweight and have few dependencies - making them ideal for lean setups with reduced attack surfaces and maintenance needs.

This document walks you through the installation and configuration of Lighttpd with Certbot for SSL certificate issuance/management, and rsync for SSH-based file transfers from a remote system.

This document, based on its scope, assumes you use DigitalOcean for DNS hosting and have a fresh Debian 13 VM - including a standard user with Sudo rights.

The contents of this article will work with other DNS providers. Steps specific to DigitalOcean are marked with a label for your convenience. Substitute those steps with ones for your DNS provider, and you’re good to go.

This setup is ideal for hosting Hugo sites, as well as other static websites, and rsync can be incorporated into many deployment workflows - including Gitea actions via the local instance.

Create the Deploy User

Create a dedicated deploy user for pushing site files via rsync over SSH:

1
sudo adduser --disabled-password --gecos "" deploy

Generate an SSH key pair on your local machine (if you don’t already have one):

A web server isn’t a system that should need to contain both a private and public key - so don’t generate your SSH key pair on one. Practice good security hygiene whenever possible.

1
ssh-keygen -t ed25519 -C "deploy@hugo-host"

Copy the public key to the server:

1
ssh-copy-id deploy@your-server-ip

You can also paste the contents of the public key into /home/deploy/authorized_keys and then set permissions:

1
sudo chmod 600 /home/deploy/authorized_keys

Test the Account

Connect to the server using the deploy user and SSH key pair:

1
ssh -i /path/to/private_key username@hostname

If you encounter a problem, double-check the public key in authorized_keys and then check permissions. If the user account can’t access the file containing your public key, it can’t authenticate using the key pair and the login process will fail.

SSH Configuration

As a general best practice, disable both Root and password-based logins.

Ensure a standard user (not deploy) has sudo rights before continuing.

1
sudo nano /etc/ssh/sshd_config
  1. Add or find PermitRootLogin and set its value to no
  2. Add or find PasswordAuthentication and set its value to no
  3. Add or find KbdInteractiveAuthentication and set its value to no

Save the file and restart the service:

1
sudo systemctl restart ssh

Software Installation

Install all required packages (except Certbot) in a single command:

1
sudo apt install -y lighttpd rsync python3-pip python3-venv libaugeas0

Install Certbot via pip

The DigitalOcean DNS plugin for Certbot was removed from the Debian 13 repositories, so we’re installing via pip.

Create a virtual environment for Certbot and install Certbot and the DigitalOcean DNS plugin. Create a symbolic link so the certbot command works without supplying a full path to the executable:

1
2
3
4
sudo python3 -m venv /opt/certbot
sudo /opt/certbot/bin/pip install --upgrade pip
sudo /opt/certbot/bin/pip install certbot certbot-dns-digitalocean
sudo ln -s /opt/certbot/bin/certbot /usr/bin/certbot

Make sure Certbot is available:

1
sudo certbot --version

Certbot Configuration

Create directories to store certificates and configuration data:

1
2
sudo mkdir -p /etc/lighttpd/certs/
sudo mkdir -p /etc/letsencrypt

Renewal Hook

The established hostname-based pattern is required for the Certbot renewal hook to function properly.

Create a shell script file:

1
sudo nano /etc/letsencrypt/renewal-hooks/deploy/lighttpd-deploy.sh

Paste the following into the lighttpd-deploy.sh file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
set -euo pipefail

CERT_NAME=$(basename "$RENEWED_LINEAGE")
DEST="/etc/lighttpd/certs/${CERT_NAME}.pem"

cat "$RENEWED_LINEAGE/privkey.pem" \
    "$RENEWED_LINEAGE/fullchain.pem" \
    > "$DEST"

chmod 600 "$DEST"
systemctl reload lighttpd

Save the script and make it executable:

1
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/lighttpd-deploy.sh

DigitalOcean Access Token

If you are using a different DNS provider that has an API supported by Certbot, skip this step and replace it with one for your provider and then resume the procedure at the next step.

Log in to your DigitalOcean control panel and create a new access token. Provide a clear name for the token related to your hosting setup, especially if using DNS01 authentication other services.

Lock the access token down to domain: read and write permissions.

Create a secured configuration file to hold your DigitalOcean Personal Access Token:

1
sudo nano /etc/letsencrypt/digitalocean.ini

Paste your token using the format below:

1
dns_digitalocean_token = your_digitalocean_api_token_here

Lock down file permissions immediately so only root can view the token:

1
sudo chmod 600 /etc/letsencrypt/digitalocean.ini

Requesting a Certificate

Wildcards are handy - be sure to take note of the file names when creating one. Use the --cert-name <name> flag if requesting only a wildcard certificate.

Sample request w/ standard and wildcard certificates:

1
2
3
4
5
6
7
8
sudo certbot certonly \
  --dns-digitalocean \
  --dns-digitalocean-credentials /etc/letsencrypt/digitalocean.ini \
  -d example.com \
  -d '*.example.com' \
  --non-interactive \
  --agree-tos \
  --email admin@example.com

The renewal hook will run after the request process has been completed.

Renewal Dry Run

Make sure that you’re configuration will allow for a successful renewal before your new certificates expire. Run the following command for a dry run on all certificates:

1
sudo certbot renew --dry-run

Automatic Certificate Renewals

Choose and implement an automatic certificate renewal process while handling initial configuration - skipping it now, means likely forgetting it until the certificate has expired, and that’s no fun.

Option 1: Create your own systemd timer

Create a service:

1
nano /etc/systemd/system/certbot-renew.service

Add the following - adjust the path if your virtual environment exists somewhere else:

1
2
3
4
5
6
[Unit]
Description=Renew Let's Encrypt certificates

[Service]
Type=oneshot
ExecStart=/opt/certbot/bin/certbot renew --quiet

Create the timer:

1
nano /etc/systemd/system/certbot-renew.timer

Add the following:

1
2
3
4
5
6
7
8
9
[Unit]
Description=Run Certbot twice daily

[Timer]
OnCalendar=*-*-* 03,15:00:00
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

1
2
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer

Check:

1
systemctl list-timers

Option 2: Cron

A simple cron job works fine, too:

1
15 3,15 * * * root /opt/certbot/bin/certbot renew --quiet

Again, adjust the path to your virtual environment.

Test everything

Don’t wait 90 days to find out something broke.

Run:

1
sudo certbot renew --dry-run

You should see something similar to:

1
Congratulations, all simulated renewals succeeded.

Your deploy hook should execute against the temporary test certificate

Lighttpd Configuration

Lighttpd is pronounced /lighty/

The default configuration for Lighttpd is located in the /etc/lighttpd/lighttpd.conf file. Edit this file to configure Lighttpd to serve your website.

Site Root Directory

For a single website, set the document root:

1
server.document-root = /var/www/html
When configuring a Lighttpd instance for virtual hosts, keep the location of site roots simple:

/var/www/site.example.com

If running services that include files to be hosted, create symbolic links in /var/www so you know what’s running at a glance.

Create the site’s root directory:

1
sudo mkdir -p /var/www/html

On the server, lock down the deploy account to key-only authentication and grant write access to the web root:

1
2
3
sudo usermod -aG www-data deploy
sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 775 /var/www/html

Restart Lighttpd to pick up group membership:

1
sudo systemctl restart lighttpd

Virtual Hosts

This setup uses very few resources, and hosting multiple static sites is really simple with Lighttpd. Conditional blocks are used to define virtual hosts and contain their configuration details.

If you’re using virtual hosts, be sure to point the default site to a default page or redirect to another site.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$HTTP[\"host\"] =~ \"blog.chiron.home.foundry81.com\" {
    server.document-root = \"/var/www/blog.chiron.home.foundry81.com/\"

    # Serve index.html for directory requests
    index-file.names = (\"index.html\", \"index.htm\")

    # Static file caching for Hugo assets
    $HTTP[\"url\"] =~ \"\\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|webp)$\" {
        setenv.add-response-header = (
            \"Cache-Control\" => \"public, max-age=31536000, immutable\"
        )
    }
}

Be sure to have a default site configured - and if you’re using SSL, make sure the default is properly configured with a certificate, or Lighttpd will not run as expected

Add Certs to Lighttpd

Open your main server configuration file:

1
sudo nano /etc/lighttpd/lighttpd.conf

Add your modern SSL listener socket at the bottom of the file, pointing to the certificate directory used by the renewal hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$SERVER[\"socket\"] == \":443\" {
    ssl.engine                  = \"enable\"
    ssl.pemfile                 = \"/etc/lighttpd/certs/example.com.pem\"
    # Note: ssl.ca-file is not needed for Lighttpd when using a combined PEM file
    ssl.openssl.ssl-conf-cmd    = (\"MinProtocol\" => \"TLSv1.2\")
    ssl.cipher-list             = \"EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH\"
    ssl.honor-cipher-order      = \"enable\"
    # Disable old SSL/TLS versions (defense-in-depth)
    ssl.use-sslv2               = \"disable\"
    ssl.use-sslv3               = \"disable\"
    ssl.use-tlsv1               = \"disable\"
    ssl.use-tlsv1.1             = \"disable\"
    setenv.add-response-header  = (
        \"Strict-Transport-Security\" => \"max-age=31536000; includeSubDomains; preload\",
        \"X-Content-Type-Options\" => \"nosniff\",
        \"X-Frame-Options\" => \"DENY\",
        \"Referrer-Policy\" => \"strict-origin-when-cross-origin\",
        \"Permissions-Policy\" => \"geolocation=(), microphone=(), camera=()\"
    )

    # Virtual Host SSL Config
    $HTTP[\"host\"] == \"subdomain.example.com\" {
        server.document-root = \"/var/www/subdomain.example.com\"
        ssl.pemfile          = \"/etc/lighttpd/certs/subdomain.example.com.pem\"
    }
}

Lighttpd fails if you have a server socket block without a default cert.

With Lighttpd, the :443 socket has to initialize TLS before it knows which hostname the client is requesting via SNI. That means the socket itself needs a certificate to load successfully. The per-vhost ssl.pemfile directives are used after the TLS handshake begins and the SNI hostname is available.

This isn’t just a fallback for clients; it satisfies Lighttpd’s requirement that the socket has a certificate to initialize with. If the default certificate is omitted, Lighttpd fails to start rather than simply using the first vhost’s certificate - if one exists.

Enable the SSL module and restart your web server to apply the changes:

1
2
3
sudo lighty-enable-mod ssl
sudo lighttpd -t -f /etc/lighttpd/lighttpd.conf
sudo systemctl restart lighttpd

Enable the Redirect Module

Enable the required redirect module using the native lighttpd configuration helper tools:

1
sudo lighty-enable-mod redirect

Configure the Global 80 → 443 Redirection Rule

Open your main configuration file:

1
sudo nano /etc/lighttpd/lighttpd.conf

Add the following configuration block. Ensure it is placed outside your $SERVER[\"socket\"] == \":443\" block so it catches global port 80 requests:

1
2
3
4
5
# Redirect all HTTP (port 80) traffic to HTTPS (port 443)
$HTTP[\"scheme\"] == \"http\" {
    url.redirect = (\"^/(.*)\" => \"https://%0/$1\")
    url.redirect-code = 301
}

Restart Lighttpd:

1
sudo systemctl restart lighttpd

EOF.

A Few Notes Before Replicating This

The documentation above describes my setup - and that’s an important distinction to make. Linux distributions, file locations, network architectures, and preferences differ.

Maybe you’re terminating TLS somewhere else, or you have three sites instead of one - or you’d prefer to have it all in a container because that’s how you prefer to manage things. It’s all good; this is simply one way to run Lighttpd. There are also a few deliberate choices in the configuration that are worth calling out.

Keep the web server’s job small

Lighttpd serves up the generated Hugo content - it doesn’t build anything. That means the system serving the website doesn’t need Hugo installed just to answer HTTP requests. It also means that a website deployment can be treated as a content update rather than an application deployment. Build the site, place the files where they belong, and serve them.

Don’t give the service permissions it doesn’t need

A web server that only needs to read static content shouldn’t need broad write access to that content. That’s not a revolutionary security discovery, it’s just a useful property of static websites that feel like a shout-out to GeoCities and a simpler way of delivering information.

If a system doesn’t need to modify files, don’t give it the ability to do so. The same principle applies to the rest of the system - the fewer places a service can reach, the fewer things that are to worry about if something goes wrong.

Don’t make one component responsible for everything

There are other layers around this setup that deal with things like network access and TLS. I don’t expect Lighttpd itself to be my entire security strategy.

I prefer having several relatively simple layers rather than making one component responsible for everything.

If I’ve made a mistake in one place, hopefully the other layers make that mistake less interesting. That’s defense in depth as I actually practice it: mostly a collection of small, simple decisions.

Test the actual site

It’s easy to test the homepage and declare victory - don’t.

For a Hugo site, I want to know that normal pages work, generated sections work, categories and tags work, assets load, and the site’s 404 behavior works. The fact that / returns a page doesn’t tell me much about whether the rest of the site works.

Static sites are wonderfully predictable, so take advantage of that. If Hugo generated a file, I should be able to find it. If Lighttpd is supposed to serve it, I should be able to request it. When something doesn’t work, that gives me a very small set of places to look.

Why I Keep Documentation Like This

One of the reasons I maintain internal documentation for my Homelab is that I don’t want every future change to become an hunt for “why” I did something a few years before. I’ve done enough infrastructure work to know how this usually goes. You build something, it works, and six months later you need to change something and realize that looking for technical documentation is a weird form one-way time travel.

The goal isn’t to create a perfect reference manual. The goal is to make Future Me slightly less annoyed with Past Me. If somebody else finds it to be useful, that’s a bonus.

That’s Pretty Much It

There’s not a lot more to this particular setup. Hugo builds the site, Lighttpd serves it, Certbot handles TLS cert maintenance and Rsync makes pushing files over an SSH connection near effortless.

The surrounding Homelab handles the other infrastructure concerns, and since the sites are static, there isn’t much else that needs to happen - and that’s exactly what I want.

I’ve spent plenty of time in environments where serving a web page required an impressive collection of infrastructure - even a short time with a company that piggybacked onto MLB’s infrastructure in the 2010s.

There’s a place for that, but there’s also something deeply satisfying about being able to look at a machine and say “this thing serves some HTML” and then walk away.

If you’re looking for a simple way to serve a Hugo site, hopefully the documentation above gives you enough to get started. If you’re already running something else, that’s fine too. I’m not trying to convert anyone to Lighttpd; I’m just opening the door to the Homelab and showing you what’s on the other side.

Further Reading

Getting in Touch

Have a question? Want to talk tech? Curious about something you saw here?

Reach out. I’m always up for a good conversation, answering a thoughtful question, or geeking out over infrastructure, design, or the overlap between them. I’ll get back to you when I can.

Looking to build something? Launch something? Fix something?

If you see alignment between your work and mine, let’s explore it. I collaborate with IT organizations, creative teams, and builders who value thoughtful execution and clear outcomes. If it’s a good fit, we’ll make it happen.