Building a Caddy container stack for easy HTTPS with Docker and Ghost

I'm building a Caddy-stack to serve up HTTPS sessions, with a Ghost back end, using Docker containers, orchestrated with Docker Compose. You'll easily be able to replace the Ghost back end with other container-based apps such as Nginx or Wordpress. Updated January 2022.

Building a Caddy container stack for easy HTTPS with Docker and Ghost

I'm building a Caddy-stack to serve up HTTPS sessions, with a Ghost back end, using Docker containers, orchestrated with Docker Compose. You'll easily be able to replace the Ghost back end with other container-based apps such as Nginx or Wordpress.

As a longtime user of Traefik, I was pleasantly surprised when I tripped over Caddy. It's another proxy application like Traefik that ticks all the boxes. Reverse proxy, routing, https, open source, container based, easy to use. And that last one is crucial.

While Traefik is very cool and a great community, I had been beating my head against the wall trying to get a really basic running model of their new version 2 up and running. The config and syntax complexities were dragging me down, and there weren't really many resources or HTTPS examples online. The tech road was meandering in a wildly zig zag way.

In fact, it was while digging around for even a simple example of Traefik V2 with Docker Compose + HTTPS that I found a blogger - also struggling to get it going - who wrote the fateful words, "discovered caddy, seems simpler". And here we are. A cloud test server and less than an hour later, it was up and running.

Incidentally, this is my first outing making use of the excellent Diagrams.net for my diagram, with a cool sketch library from Rough-JS.

Prerequisites for the build

I am using a $5 AWS Lightsail server in the Frankfurt region. Most VPS's (Virtual Private Server) will do, I have my farm in the same place, I find it a fair deal for the included bandwidth and like having the vast AWS infrastructure nearby should I want to make use of it. Variations on this environment might work but this is what works here.

  • An AWS Lightsail $5 VPS with 1GB memory (if using, add HTTPS to the firewall)
  • Ubuntu 20.04
  • A running docker compose environment, built as per this then this great DigitalOcean article. At the time of writing the latest stable used here is 1.29.2.
  • A domain name or subdomain name you can point to the VPS public IP address. I have separate domains for the server (SSH, SFTP) and the web site itself.
  • Optional - an IP whitelist on your cloud server so only you can access the test application

Getting a base Caddy set up

I normally keep all my docker stuff under custom directory /data, but it's up to you. Wherever you see /data/caddy, replace with your own core directory. There are a few volumes at play.

  • /data/caddy : the Caddy and Compose root - directory
  • /data/caddy/Caddyfile : the centre of the universe - file
  • /data/caddy/data : the house for certificates (optional) - directory
  • /data/caddy/config : JSON config files (optional) - directory

Create some directories

$ sudo mkdir /data
$ sudo chown ubuntu:ubuntu /data
$ mkdir -p /data/caddy/data
$ mkdir -p /data/caddy/config
$ cd /data/caddy

Create a Compose file

I also typically keep my Docker Compose control file in the same place as the "head vampire" container, in this case Caddy. It can be anywhere that suits you. With your favourite text editor, create & open docker-compose.yml and seed with this.

version: "3.7"

networks:
        web:
                external: true
        internal:
                external: false
                driver: bridge

services:
        caddy:
                image: caddy:2-alpine
                restart: unless-stopped
                ports:
                        - "80:80"
                        - "443:443"
                volumes:
                        - /data/caddy/Caddyfile:/etc/caddy/Caddyfile
                        - /data/caddy/data:/data # Optional
                        - /data/caddy/config:/config # Optional
                networks:
                        - web
                        - internal

Caddy will function with only the Caddyfile volume mapped, and is fine for testing, but having the optional data and config volumes separated allows externalising of some key files, for backup or consistency when upgrading the core container image.

Create a Caddyfile

A Caddyfile is structured to have blocks for global settings, snippets, and server. The overview is on the Caddy site here. I am using the following, you should be able to create the same, updating with your domains and email. If using my example paths create with your favourite editor /data/caddy/Caddyfile.

{
    # Global options block. Entirely optional, https is on by default
    # Optional email key for lets encrypt
    email youremail@domain.com 
    # Optional staging lets encrypt for testing. Comment out for production.
    # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
myghost.mydomain.com {
    reverse_proxy ghost:2368
}
www.myghost.mydomain.com {
    redir https://myghost.mydomain.com{uri}
}

The optional email field here is linked to the LE (Let's Encrypt) certificates Caddy will issue automatically. If you are going to be hacking around with this I recommend using the LE staging server, just remember to comment the acme_ca entry out before going live.

Create a Docker network

If you haven't already, create the "web" and "internal" networks:

$ docker network create web
$ docker network create --internal caddy_internal

You can check with: docker network ls

Point your domain

Assign your domain to the IP address of your VPS. Ideally use a static IP on your instance. In this case it could be myghost.mydomain.com or *.mydomain.com.

Doing a lookup for myghost.mydomain.com should resolve to the VPS IP address.

Start the Caddy container

Running a docker-compose up -d should bring up the caddy container. If you do a docker ps you should see something like this:

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                                NAMES
fc43f933e554        caddy:2-alpine      "caddy run --config …"   24 hours ago        Up 14 seconds       0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 2019/tcp   caddy_caddy_1

If all looks good, you can proceed to adding an application container.

Add Ghost to the docker compose file

This doesn't have to be Ghost. You could easily add a different container that serves up data, such as Nginx. Be aware though, we have added the Ghost port in the Caddyfile. Remember the Caddyfile entry ghost:2368 - this would need to be replaced with the name and port for your chosen service. This name must match the name of your compose entry, as I have with "ghost" below.

Edit the docker compose file with your favourite editor and add this.

        ghost:
                image: ghost:4-alpine
                restart: unless-stopped
                environment:
                        - url=https://myghost.mydomain.com # Change to your domain
                volumes:
                        - /data/myghostapp:/var/lib/ghost/content
                networks:
                        - internal
  • Create the Ghost application volume on the server with mkdir /data/myghostapp
  • Change the domain in the URL parameter to be the domain or subdomain you are pointing to this server. This should match the domain in the Caddyfile.
  • If you are looking to build this site to keep, check Docker hub for an up to date Ghost image. At the time of writing it's V4.33.1, and I can tolerate minor version upgrades, hence I have chosen 4-alpine.

Start the Ghost container

Run docker-compose up -d and all going well you should see the images update, and the containers start.

Shortly, entering your domain into a browser such as myghost.mydomain.com should take you to the vanilla Ghost pages, ready to go. You can also test the www redirect we added to the Caddyfile, if you enter your equivalent of www.myghost.mydomain.com into the address bar, it should redirect to the main URL without the www.

If you were using the Let's Encrypt staging server, and you want to keep the stack running, comment it out from the Caddyfile at this point. You may need to scrub under the /data/caddy/data/caddy directory if sessions are getting corrupted.

Performance test for basic page loads

Server-side, Caddy is using about 14MB memory and Ghost around 98MB.
Making use of Webpagetest.org on the Ghost v4 welcome page (includes calls out to Ghost for static files), I get document complete in 6.5s with payload of 3244Kb.

The bulk of the bloat is off the shelf images pulled over the net. It improves a lot with local images and some compression. On balance it's a good approach so the distro is not shipped full of images that will only be deleted anyway.

For comparision, the homepage of Techroads which is also V4, with a Varnish cache, loads in 1.8s with payload of 695Kb. Not bad eh!

Optional: Adding SMTP mail

You may want to put some icing on the cake and add email functionality to your Ghost blog (or other app). It's pretty easy, a matter of adding variables to the environment: section of your docker compose file. Note the additional six mail lines.

        ghost:
                image: ghost:4-alpine
                restart: unless-stopped
                environment:
                        - url=https://myghost.mydomain.com # Change to your domain
                        - mail__transport=SMTP
                        - mail__options__host=smtp.yourprovider.com # Find out from provider
                        - mail__options__port=587 # Find out from provider
                        - mail__options__auth__user=yourlogin@yourprovider.com
                        - mail__options__auth__pass=Your_secure_pass
                        - mail__from=Cool Admin <youremail@yourprovider.com>
                volumes:
                        - /data/myghostapp:/var/lib/ghost/content
                networks:
                        - internal

If you don't wish to have credentials in plain text in your Compose file, the next degree of separation is to use a .env file for example. More info here.

Different SMTP providers have different levels of fussiness for your sending parameters, to limit spammers. Your current webmail likely allows it. If you want a custom provider, Mailgun seems popular but I have no personal experience with them. For testing it, you can send invites from the Ghost console, Settings -> Staff -> Invite People.

Finishing up

We wouldn't be at the end of the Tech Road without some thought for potential production use. If you are going ahead with a more permanent build of this, be sure to check out my articles on logging, and backups.

For production use you will want to beef up security. This is an example I have used, but check the Caddy docs on security headers. I've also added compression on defaults with encode.

{
    # Global options block. Entirely optional, https is on by default
    # Optional email key for lets encrypt
    email youremail@domain.com 
    # Optional staging lets encrypt for testing. Comment out for production.
    # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
myghost.mydomain.com {
    encode zstd gzip
    reverse_proxy ghost:2368
    header {
        # enable HSTS
        Strict-Transport-Security max-age=31536000;
        # disable clients from sniffing the media type
        X-Content-Type-Options nosniff
        # clickjacking protection
        X-Frame-Options DENY
        # keep referrer data off of HTTP connections
        Referrer-Policy no-referrer-when-downgrade
        # Content-Security-Policy: default-src 'self'
    }
}
www.myghost.mydomain.com {
    redir https://myghost.mydomain.com{uri}
}

On the off chance you set up automated container image updates as per my post, or have any script that runs against an old compose file location (I was previously stashing mine under /data/traefik), you will need to alter your update scripts to reflect your new compose file location.

Bear in mind you can have multiple Ghost (or anything) instances under a single Caddy proxy. A small blog by itself will have loads of headroom on a 1GB server. I did experiment with how many Ghost blogs I could stuff into one server, albeit older versions. I am building a server with 5 or so small Ghost blogs to replace a Wordpress multisite.

If you expect heavy traffic, consider a cache container.

We have scratched the surface: There is a growing set of Caddy functionality available, including regular expressions, rewrites, redirects, variable substitutions, load balancing and direct web server, described on the Caddy web site. I'm going to make it my main web broker from this point forward, and will next look at converting this very site to start using it.

💡
Updated for newer versions Jan '22. Ubuntu 20.04, Ghost 4. Since I first wrote this, techroads.org and all of my sites are on Caddy.

Main golf caddy photo courtesy of Kenan Kitchen on Unsplash.

You are welcome to comment anonymously, but bear in mind you won't get notified of any replies! Registration details (which are tiny) are stored on my private EC2 server and never shared. You can also use github creds.