How to serve a static site using Caddy

Here's a "how to" for serving a static site from Caddy, with a side of debugging advice and a few sysadmin tips.

When I started using Caddy, I read all the introductory documentation, including the quick start guides about Caddyfiles and serving static files. But I still didn't understand how simple things fit together and it took a while to work out how to serve a static site.

I'd also become used to automated deployments and was surprised by simple things like "Where should the files go on the server??" The how to covers a few of those topics as well.

As ever, skip to the TLDR if you just want the Caddyfile.

Install Caddy on server

The official website has good instructions for installing Caddy on your server. For Linux, the most common server OS, the instructions mostly involve running commands to add a new package repository.

Since there's some copy-pasting, it's worth following that old sysadmin trick: type the comment symbol # at the start of the line, then paste the command. Because the command is a comment, it won't run unexpectedly even if a newline gets copied into the terminal. When you're sure the command is right, remove the # comment symbol and run.

Check Caddy is running

By default, Caddy runs in the background via systemd. Caddy seems to automatically start itself after installation, but it's worth double-checking using


$ systemctl status caddy

# Start of output
# ● caddy.service - Caddy
#     Loaded: loaded (/lib/systemd/system/caddy.service; enabled; vendor preset: enabled)
#     Active: active (running) since Fri 2022-06-03 07:32:56 UTC; 1 weeks 5 days ago
#       Docs: https://caddyserver.com/docs/

Most of the time, you'll want Caddy to be running and enabled. (Where "running" means that Caddy is on and "enabled" means that Caddy will automatically start after a server reboot.) Luckily, these seem to be the default settings.

Put the static files on the server

For the purposes of the article, I'm going to assume the static site has a really simple directory structure:


static-site/
    index.html
    a-post.html
    styles.css

At this stage, there are a couple of decisions to make.

Decision one is how you'll put files onto the server. There are lots of ways of doing that, so it's really down to your preferences. For simple static sites, I like to make the directory a git repo, clone it on the server and git pull to get updates. Depending on your situation, all sorts of things from rsync to a CI/CD pipeline could be decent options.

Decision two is where the static site directory will live. I've found it most convenient to put the static site directory inside the directory /var/www, e.g. /var/www/my-static-site. It seems to be a common location, so there's a decent chance it will be familiar to other developers. Log files often go into the nearby /var/log (explained later), which keeps everything close together in the directory structure.

Once you've decided where the files are going and how you'll do it, put the static files onto the server.

After copying the files, make sure the caddy user can read the static site directory. If the caddy user can't access the directory, you'll get get permission errors when Caddy tries to access and serve the files.

For those learning Linux, ls -l is a useful way of checking the owner and group permissions of the directory. If the output is scary and unfamiliar, it's worth reading this short overview of file permissions.

A quick detour: start with HTTP

For simple static sites, I tend to start by serving the files via HTTP and without a domain.

A great feature of Caddy is the "automatic HTTPS", which means that Caddy gets the TLS certificate and keeps it renewed. But there's a gotcha - if you make lots of mistakes while setting up, you'll hit the rate limits of certificate providers. The Caddy documentation suggests the longest ban is 1 week.

So for a brand new Caddyfile I tend to check that everything works over HTTP. When I'm confident in the basic setup, I move to HTTPS.

An alternative approach is to use a certificate authority's staging URL while you're getting ready. Then swap to the certificate authority's main URL when going into production. It's worth considering for more complex sites, espeially if you might send or receive sensitive data during the initial setup.

Write a simple Caddyfile

At this point Caddy is running but not actually serving anything. It's time to write a Caddyfile, which will tell Caddy how to serve the site.

The default Caddyfile, created by Caddy on installation, is /etc/caddy/Caddyfile. Edit that file to add your own settings.

This particular Caddyfile serves a static site via port 80 (HTTP, not HTTPS).


:80 {
    root * /var/www/my-static-site
    encode gzip
    file_server {
        hide .git
    }

    log {
        output file /var/log/caddy/my-static-site.log
    }

    header {
        ?Cache-Control "max-age=1800"
    }
}

Caddy has good defaults, so there's a lot less boilerplate than I tend to see in Nginx configs. You might even find that some of these settings are irrelevant to you and can be removed.

The :80 is a "block" and everything inside it is scoped to that block. You can have different blocks for different domains, perhaps serving different static sites.

In this case, Caddy is told to act as a file server, returning files from the directory /var/www/my-static-site. Caddy will return the index.html when someone goes to your root URL. (The user doesn't have to include index.html at the end of the URL.)

Inside the file_server block is a line that says Caddy should hide the .git directory. In other words, Caddy shouldn't serve those files and users shouldn't be able to navigate to https://example.com/.git

The line encode gzip means the files will be compressed using gzip and the Content-Encoding in the representation header will say so. Compressing the response is recommended but not required.

The log block specifies what should happen to access logs. (The access logs are different to the Caddy runtime logs, which are accessed through systemd, meaning systemctl status caddy.) In this case, the access logs will be saved to a file. Caddy creates its own log folder at /var/log/caddy and ensures the caddy user owns the directory, so I've found it simplest to put the access logs there. Caddy will use its defaults for log rotation.

The header block specifies the Cache-Control, a.k.a. the header that tells browsers how long they should cache resources. Caddy usually passes through the Cache-Control header from the upstream server, which might be something like Gunicorn or uWSGI if you have a Python web application. But in this case there's no upstream server and no default Cache-Control. If you're minimising and versioning assets like CSS (e.g. with a hash in the file name), you might prefer to vary the Cache-Control so those files are cached forever. The Caddy docs have a few examples of this. Setting the Cache-Control is recommended but not essential.

Use the new Caddyfile

When your Caddyfile is ready, use systemctl reload caddy to tell Caddy to use the new version. Your static site should appear at http://server.ip.address

(It's the server's IP address because no domain was specified and it's http:// because there's no certificate yet.)

It isn't working...

This doesn't always work. Some tips for debugging:

  1. Check Caddy's runtime logs using systemctl status caddy
    • In the summary at the top, is Caddy "active (running)"? If it isn't, systemd isn't running Caddy. Perhaps you restarted the server and didn't have Caddy set to "enabled" (auto-restarting). If that's the case, try systemctl start caddy. If that isn't the problem, you'll need to restart Caddy and carefully read the information that systemd provides (e.g. about why it can't run Caddy), then do some Googling.
    • Are there any runtime logs with "level": "error"? If yes, Caddy is running but has some internal error. Most often I've found the Caddyfile is invalid. Careful reading of the error messages usually reveals the reason.
  2. Can you access the static site from the server itself?
    • On the server, try curl localhost (or equivalent using something like wget). Do you get the expected response? If that works but you can't access the site in your browser, it suggests that Caddy it serving the files but they can't be seen outside the server. That suggests a firewall error.
    • Do you have ufw running? (Check using ufw status.)
    • Does your cloud provider have a firewall whose settings must be changed via the web interface?

Edit Caddyfile to serve via HTTPS on your domain

If you can see your site in the browser, it's time to serve the site via HTTPS on your domain.

In your domain name provider's web interface, you'll need to set up the DNS A record and possibly other records. (If you aren't sure what that means, don't worry. It's what tells the world that a particular domain points to a particular IP address. The ins and outs are beyond the scope of this how to, but your domain name provider probably has simple instructions for setting up your DNS records.)

Once the DNS records are setup, edit the Caddyfile to use the domain name rather than port 80.

The diff will look something like this:


- :80 {
+ mydomain.com {
    root * /var/www/my-static-site
    encode gzip
    file_server {
        hide .git
    }

    log {
        output file /var/log/caddy/my-static-site.log
    }

    header {
        ?Cache-Control "max-age=1800"
    }
}

Only the start of the block has changed.

In this example, the site address is https://mydomain.com. If you've decided to use the www subdomain, the diff should instead begin:


- :80 {
+ www.mydomain.com {

Tell Caddy to use the new version of the file by running systemctl reload caddy.

Wait a few moments and check the runtime logs using systemctl status caddy. Hopefully you'll see messages about Caddy sucessfully getting a TLS certificate.

Check you can access https://mydomain.com from a web browser, replacing "mydomain" with your own domain.

It isn't working... (round 2)

Hopefully you tested everything earlier via HTTP and everything now works via HTTPS.

If the HTTP version was fine but the HTTPS version isn't working, the most likely culprit is a failed ACME challenge. Check the runtime logs using systemctl status caddy.

If the logs confirm the challenge failed, check your DNS records and the server firewall. In particular, are you allowing traffic on port 443, the standard port for HTTPS traffic?

It's also worth re-reading the first It isn't working... section and going through those debugging steps again.

These are by far the most common problems that I've encountered using Caddy, so hopefully your static site will be up and running in no time.

TLDR

The TLDR feels redundant on this one because the entire Caddyfile was included several times in the article. But here it is anyway.

Edit the default Caddyfile, which is /etc/caddy/Caddyfile


# /etc/caddy/Caddyfile

mydomain.com {
    root * /var/www/my-static-site
    encode gzip
    file_server {
        hide .git
    }

    log {
        output file /var/log/caddy/my-static-site.log
    }

    header {
        ?Cache-Control "max-age=1800"
    }
}

Use the command systemctl reload caddy to tell Caddy to use the new version of the Caddyfile.

Useful things