4 min read

Setting up Ghost

I am self-hosting this blog using the unofficial official Docker image on Nomad, with slight modifications.

This blog now runs on Ghost. This is something I wanted to do since Ghost was released, but I didn't have a need for it. Ghost is a blogging platform first released in 2012 as a Node.js replacement for WordPress. Since then and five releases later, Ghost remains a free and open-source blogging platform and includes newsletter and subscription features.

I am self-hosting this blog using the unofficial official[1] Docker image on Nomad, with slight modifications.

Porting the Docker Compose File to Nomad

The first step in setting up Ghost was porting the example docker-compose file to a Nomad job.

The porting is straightforward, requiring the usual translations between different configurations:

job "ghost" {
  type = "service"

  group "ghost" {
  count = 1

  restart {
    attempts = 2
    interval = "2m"
    delay    = "30s"
    mode     = "fail"
  }

  network {
    port "http" {
      to = 2368
    }
  }

  service {
    name = "ghost"
    port = "http"

    check {
       type     = "tcp"
       interval = "5s"
       timeout  = "2s"
    }
  }
    task "web" {
      driver = "docker"

      config {
        image = "ghost:5"

        mount {
          type     = "bind"
          target   = "/var/lib/ghost/content"
          source   = "/data/ghost/"
          readonly = false
        }
        
        ports = ["http"]
        }
        
        env {
          database__client = "sqlite3"
          database__connection__filename = "/var/lib/ghost/content/data/ghost.db"
          database__useNullAsDefault = "true"
          database__debug = "false"
        }

      resources {
        cpu    = 200
        memory = 500
      }
    }
  }
}

Using SQLite

As you might notice, I am not using the strongly suggested MySQL 8 database, opting to use SQLite in production. This decision is due to two things.

First, I was not able to set up MySQL 8 on Nomad. The MySQL server refuses connections with a Host '<docker IP>' is not allowed to connect to this MySQL server. For what I can gather online, this is a security feature in the MySQL 8 Docker image to prevent connections from unknown sources. I was not able to overwrite the origin host through the environment variable[2]. The other option would be not to mount a local folder and instead using a Docker volume for the MySQL data. For some reason, it should fix this security problem. I didn't want to do it because I prefer to use a host mount rather than a Docker volume for my Nomad jobs.

It is still possible to use SQLite as the datastore for Ghost, even if this option was discontinued for production environments with the release of Ghost 5. As Ghost uses Knex.js under the hood, SQLite should still work without big issues—and it's still used in Ghost's development mode.

Using SQLite just requires setting up the database__client environment variable to sqlite3 and to specify the location of the database file with database__connection__filename. For now, the site is up and running, without the overhead of a MySQL 8 server.

Updating SQLite PRAGMAs

PRAGMAS are the internal SQLite settings that govern how the database works. The PRAGMA defaults take a conservative approach, allowing SQLite to work on a wide range of platforms and situations, while turning off some of the options that can help in a modern web application, including concurrent read and writes, journal size, and database write sync mode. I adjusted these values following SQLite fine-tuning suggestions for web applications.

Let's review each option in detail.

PRAGMA journal_mode = WAL;

This first option changes SQLite journaling mode from the default Rollback journal to the optional Write-Ahead Log (WAL).

The Rollback journal is the original database update procedure, which, in broad strokes, copies the current database state into a rollback file, updates the database, and then removes the rollback file if the update was successful or restores the original state if unsuccessful. This procedure is generally slower than the WAL implementation, but it is somewhat safer as it allows for atomic commits and rollbacks by writing changes directly to the database and keeping the original data in a separate rollback journal in case it's needed.

On the other hand, WAL mode reverses this model. The content is preserved in the original database file, and commits are appended to the write-ahead log file. Commits happen without writes to the original database file. This allows for concurrent reads and writes without resulting in database locks, at the expense of some disadvantages.

PRAGMA synchronous = NORMAL;

This option controls when and how often SQLite flushes content to disk. Normal mode syncs every 1000 pages written, and it's a good complement to WAL mode. This is in contrast with the default FULL mode, which syncs at each write. In short, Normal mode reduces the number of times that data is synced to disk at the cost of some reliability in case of filesystem failure.

PRAGMA journal_size_limit = 67108864; -- 64 megabytes

This option sets the size for the WAL journal. The default is -1, or no limit, which allows the WAL file to grow indefinitely. It seems that 64 megabytes is a good middle ground between allowing for the journal to function without growing to unmanageable sizes.

PRAGMA mmap_size = 134217728; -- 128 megabytes

This option controls “the maximum number of bytes of the database file that will be accessed using memory-mapped I/O”. This is the memory buffer pool that SQLite uses to work with the database. Setting it to 128 megabytes seems to be the industry standard, set by Postgres.

PRAGMA cache_size = 2000;

This option sets the page cache size. This sets the cache size to about 8 Megabytes per connection. This seems to be the agreed-upon sweetspot between practicality and manageability.

PRAGMA busy_timeout = 5000;

This final option sets the database timeout value to 5000 milliseconds—or 5 seconds. This tells SQLite how long to wait to successfully connect to the database when trying to establish a new connection. This option is useful to handle cases when the database returns a BUSY error.

Reverse Proxying

The last bit before launching the new blog was adding its routing to my Caddy Server front end. This was done in a few lines of code in my Caddyfile:

@blog host blog.ebardelli.com
handle @blog {
    {{ range service "ghost" }}
    reverse_proxy {{ .Address }}:{{ .Port }}
    {{ end }}
}

This uses Consul to store the address and port that Nomad assigned to the Ghost service and dynamically updates the Caddyfile to serve it.

To Do

There are a few things left to do to fully finish setting up Ghost. They all involve the newsletter feature and mail delivery. At this time, I have disabled the newsletter feature. I don't know who even reads this blog, so jumping into setting up a newsletter for no one seemed a bit excessive.


  1. The Docker image isn't directly maintained by the Ghost developers. It's an unofficial community-supported port to Docker that has been elevated to an official Ghost image on Docker Hub. ↩︎

  2. Allowing all origins to connect to the database with MYSQL_ROOT_HOST: '%' doesn't seem to be a good idea anyway. ↩︎