Declarative blog setup with docker-compose

Docker is neat. I don't believe it's the ultimate solution for all our software packaging and delivery problems, but still, as a way to easily run something someone else has made, it's neat. So when I decided to migrate my blog to Ghost, I though I might use the docker image they provide to deploy it on my server.

However, there is one thing that bothers me with docker: its configuration is very imperative. You type commands to set up your containers the way you want them, which is quite straightforward, but has a few inconveniences:

  • At the end, the only way to know what you have deployed is to look at your shell history (discarding the commands that were invalid, misspelled, or canceled). It is pretty impossible to reproduce the same setup one month later if you have not precisely documented your first run.
  • Upgrading is tedious. In an imperative context, upgrading means undoing some of the setup work (but not necessarily all), downloading new stuff, then re-doing the setup work (but not necessarily the exact same). Setup and upgrade are two different operations that are related but not quite the same.
  • Cleaning up (removing a container) is yet another operation with yet another set of interactions with the other operations.

In short: it's annoying to use, annoying to automate, and annoying to come back to.

I prefer nice declarative configurations. In a declarative configuration, you specify what final state you want for the system, and the tool does what's needed to put the system in that state. Setup, upgrade, and cleanup procedure are just implementations details. Upgrading a system is just a matter of saying "this parts must be at that version now".

Strangely, I've had a hard time finding the declarative configuration on top of docker I was looking for. Kubernetes seemed overkill, fig seemed dead. I finally realized that docker-compose, which is part of the docker toolbox, was exactly what I was looking for. It has a nice container setup description format in yaml, which let you do all you can do with the docker command line, but in a nice, commentable, version-controllable, file.

For a basic ghost setup it looks like this:

compiletoi:
  image: ghost
  volumes:
    - /var/lib/hosting/compiletoi:/var/lib/ghost
  environment:
    VIRTUAL_HOST: compiletoi.net
    NODE_ENV: production
nginx-proxy:  
  image: jwilder/nginx-proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - /var/run/docker.sock:/tmp/docker.sock:ro

It's a ghost blog with a nginx reverse proxy in front of it. Running it is just docker-compose up --detach, and upgrading it is just a matter of running docker-compose pull to get the new images, and re-running docker-compose up --detach to re-create the containers. And that's it.

That's why I like declarative interfaces: simple, clean, and all the heavy-lifting is abstracted away, because once you have said what you want, the "getting it" part can be automated.


As a bonus, here is the full (slightly obfuscated) version of my docker-compose.yml file. It contains two blogs served by a single nginx reverse proxy (thanks to the amazing nginx-proxy) in https, with certificates thanks to letencrypt and letsencrypt-nginx-proxy-companion.

# First of all, the blogs
compiletoi:
  image: ghost
  volumes:
    - /var/lib/hosting/compiletoi:/var/lib/ghost
  environment:
    VIRTUAL_HOST: compiletoi.net
    LETSENCRYPT_HOST: compiletoi.net
    LETSENCRYPT_EMAIL: my.email@example.com
    NODE_ENV: production
  restart: always

otherblog:
  image: ghost
  volumes:
    - /var/lib/hosting/otherblog:/var/lib/ghost
  environment:
    VIRTUAL_HOST: blog.example.com
    LETSENCRYPT_HOST: blog.example.com
    LETSENCRYPT_EMAIL: other.email@example.com
    NODE_ENV: production
  restart: always

# Then, the nginx in front of them
nginx-proxy:
  image: jwilder/nginx-proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - /var/run/docker.sock:/tmp/docker.sock:ro
    - /etc/nginx/certs
    - /var/lib/hosting/vhost.d:/etc/nginx/vhost.d
    - /usr/share/nginx/html
  restart: always

# Finally, letsencrypt for the certificates
letsencrypt:
  image: letsencrypt-nginx-proxy-companion
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro
  volumes_from:
    - nginx-proxy
  restart: always