Skip to main content

Hosting Multiple Sites on One Host With a Caddy Proxy Server

·7 mins

As I mentioned in my recent article Choosing a CMS, I decided to trim my monthly webhosting expenditures by switching a number of Squarespace websites over to Hugo-generated static sites running inside Docker containers on a small Alpine Linux host at Linode. (That link to Linode is an affilitate link. Please consider checking it out.) When it’s all said and done, the total savings could be as much as $1,000/year.

If you’re not familiar with Docker, please read my other recent article Embracing the Docker Lifestyle before continuing. That piece, and the links included in it, introduce the core Docker functionality that is key to the way I’ve designed my hosting setup.

Project Goals #

None of the websites I was hosting on Squarespace were particularly complex. None required a database backend or advanced scripting. They’re mostly plain vanilla HTML. So the goals were pretty simple. Make the sites fast, the hosting cheap, and the updates easy. The first two were a piece of cake; the third is a matter of opinion and may not be for everyone.

Introducing Caddy #

If you’re familiar at all with hosting websites in the last 20+ years, you know about Apache. It was the first significant open source HTTP server and was (is?) still the dominant web server on the Internet. But Apache isn’t alone in the web server marketplace. For various projects over the years I’ve used nginx and lighttpd as servers, reverse proxies, and for web caching. All of them are powerful and flexible, but as an occasional programmer and web developer, I’ve often found them to be more complex than I needed. I’m sure if I were a professional sysadmin, configuring and running any of them would quickly become second nature, but that’s not my story.

I discovered Caddy somewhat randomly a year or so ago and was intrigued. It’s written in Go and has some compelling features:

  • Caddy is capable of automatically configuring itself with TLS certificates. It even automatically renews the certificates so you never have to worry about your website breaking in modern browsers when a cert expires. (See note above about not being a professional sysadmin.)
  • The Caddy server is a self-contained binary and requires no external libraries to provide its full functionality.
  • Go is arguably more secure becuase of the way it was designed.
  • Configuring the server is incredibly easy with many basic setups capable of being expressed in just a line or two in the config file.

Because of its light weight and self-contained design, Caddy is the perfect base on which to build a containerized static website for a low-powered webhost.

The Setup #

The diagram below shows the basic design of my setup.

Diagram showing the arrangement of a series of Caddy-powered Docker containers within the Docker Engine running on an Alpine Linu host at Linode. The Docker containers are arranged with the individual webhosts connected to a Caddy proxy server doing a reverse proxy for each website.
Figure 1: Configuration of Caddy-powered websites running in Docker on a small Alpine Linux host at Linode.

Each website I’m hosting lives in its own Caddy-powered container. There is another Caddy server running in its own container as a reverse proxy for the other containers. The capacity of this configuration is limited only by the capabilities of the webhost. If the $5/month Linode instance I’m using now can’t handle it, upgrading to an instance with more memory and storage is just a few clicks away in the Linode management interface. The only thing I had to do to the Linode host was to install docker.

Caddy Configuration #

Caddy stores its configuration in a Caddyfile. Here is the Caddyfile for the timwilson.info container:

:8000 {
	root * /srv
	file_server
}

This configuration file tells Caddy what to do with HTTP requests that come in on port 8000. (Because only the port is specified in this way, Caddy will not try to obtain TLS certificates.) Caddy will look to the /srv directory as the “root” document for all ("*") URIs. The file_server directive puts Caddy is standard HTTP server mode. It would be difficult to express it much more simply than that.

This configuration file is copied along with all the HTML, CSS, etc. files into the Docker image when it’s built. The container is based on the caddy:2-alpine docker image which automatically runs the Caddy server process when it starts and loads its configuration from the included Caddyfile.

The Caddyfile is exactly the same for every other website that is being hosted on this Linode instance.

The configuration for the Caddy proxy looks a bit different.

www.timwilson.info {
	redir https://timwilson.info{uri}
}
timwilson.info {
	reverse_proxy timwilson:8000
}

www.rapidsarcheryjoad.org {
	redir https://rapidsarcheryjoad.org{uri}
}
rapidsarcheryjoad.org {
	reverse_proxy rapidsarcheryjoad:8000
}

Notice that instead of specifying a port as before, this Caddyfile uses the full domain name. As a result, Caddy will make a request to the Let’s Encrypt service for proper TLS certificates. This all happens automatically in the background.

Notice that there are two sections for each website. The first one is simply a redirect for any incoming URL that has a www in front to the standard domain. In other words, a web browser request for www.timwilson.info/2023/02/blog-post-title/ will be redirected automatically to timwilson.info/2023/02/blog-post-title/. This is a matter of preference. I find it simpler to redirect rather than mess with the www.* domain.

The second section defines the reverse proxy. For example, any HTTP requests for timwilson.info will be passed transparently to port 8000 at the docker container identified as timwilson. The reponse from the timwilson container will be encrypted by the Caddy proxy and sent back to the user’s web browser.

Starting and Stopping the Containers #

There’s another tool I use to coordinate the starting, stopping, and communication among the various docker containers. It’s called docker-compose and usually requires a separate download. The docker-compose.yml file for this setup follows:

version: "3.7"

services:
  proxy:
    image: caddy:2-alpine
    container_name: proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy_data:/data
      - ./caddy_config:/config
    networks:
      - caddy

  timwilson:
    image: timothydwilson/timwilson.info:latest
    container_name: timwilson
    restart: unless-stopped
    depends_on:
      - proxy
    expose:
      - "8000"
    volumes:
      - ./caddy_data:/data
      - ./timwilson_caddy_config:/config
    networks:
      - caddy

  rapidsarcheryjoad:
    image: timothydwilson/rapidsarcheryjoad.org:latest
    container_name: rapidsarcheryjoad
    restart: unless-stopped
    depends_on:
      - proxy
    expose:
      - "8000"
    volumes:
      - ./caddy_data:/data
      - ./joad_caddy_config:/config
    networks:
      - caddy

networks:
  caddy:
    external: true

Notice that there’s a separate section for each container that defines which docker image to use, the name of the container, ports to expose, volumes on the file system where the container can persist data, and the internal network the containers use to communicate. There is a ton of information available on how to create a docker-compose.yml file, so I won’t go any farther here.

When I’ve built a new website image (after completing this blog post, for example) and uploaded it to hub.docker.com, I ssh to my webhost and pull the new image like this:

$ sudo docker pull timothydwilson/timwilson.info:latest

Typing

$ sudo docker-compose stop

stops all the docker containers, while

$ sudo docker-compose up -d

starts the containers is “detached” mode so you get your system prompt back.

$ sudo docker container ls

will list all the running containers on the system.

The final contents of the directory where I store all this looks like:

$ tree -l -L 2
.
├── Caddyfile
├── caddy_config
│   └── caddy
├── caddy_data
│   └── caddy
├── docker-compose.yml
├── joad_caddy_config
│   └── caddy
└── timwilson_caddy_config
    └── caddy

where you can see the directories that Caddy uses to store the site-specific configuration details (which are autogenerated based on each site’s Caddyfile), the Caddyfile associated with the Caddy proxy, and the docker-compose.yml file.

Expansion #

So what would the process look like to add another website to this Linode host?

  1. Build another docker container with the website content for the new site and upload it to hub.docker.com so it can be retrieved by the webhost.
  2. Redirect DNS to the IP address of my webhost.
  3. Add a new section to the docker-compose.yml file for the new site. The specifications for all the websites are almost identical in the file except for the image:, container_name:, and volumes:.
  4. Create a volume where caddy can store the configuration for the new website using the command sudo docker volume create <some volume name>. (Docker will stop you the first time you try to start the containers if you neglect this step.)
  5. Add entries for the new site to the Caddyfile on the Caddy proxy so the content is reverse proxied correctly.
  6. Profit!!

Acknowledgments #

It look me a while to get this figured out, and I want to shout out the generous members of the Caddy Community forum for their information. francislavoie and Whitestrake were especially detailed in their responses to my questions, and it helped immensely.