Self-hosting Ghost with Traefik (and Sendgrid for email)

A vector image for my website with the keywords ghost traefik selfhost and sendgrid
A DALL-E generated image using the following prompt: A vector image for my website with the keywords ghost traefik selfhost and sendgrid

I've had some folks ask for details about how I host this blog, so I'm sharing my configuration.

This Ghost blog is self-hosted on a VPS (Virtual Private Sever) using docker compose traefik and Sendgrid. Traefik is a reverse proxy that I've built quite a lot of knowledge about over the last several years, and developed a bit of a pattern that I like. It handles all the SSL certs for me, and I can plug in whatever app by way of docker compose. This article presumes that you've already got Traefik installed, and that it listens for new docker containers advertising that they exist.


You'll need three files in any directory owned by you:

  • docker-compose.yaml
  • .env
  • config.production.json

The docker-compose.yaml file looks like so:

services:
  ghost:
    #build: ./ghost/
    image: ghost:5.97.3
    ports:
      - "2368:2368"
    restart: always
    volumes:
      - ./content/images:/var/lib/ghost/content/images
      - ./content/themes:/var/lib/ghost/content/themes
      - ./content/data:/var/lib/ghost/content/data
      - ./config.production.json:/var/lib/ghost/config.development.json
    labels:
      - "traefik.port=2368"
      - "traefik.enable=true"
      - "traefik.http.routers.blog.rule=Host(`blog.your-domain.com`) || Host(`blog-admin.your-domain.com`) || Host(`www.your-domain.com`) || Host(`your-domain.com`)"
      - "traefik.http.routers.blog.entrypoints=websecure"
      - "traefik.http.routers.blog.tls=true"
      - "traefik.http.routers.blog.tls.certresolver=lets-encrypt"
      - "com.centurylinklabs.watchtower.enable=true"
    networks:
      - internal
      - web
  database:
    image: mariadb:11.5
    restart: always
    env_file:
      - .env
    networks:
      - internal
    hostname: db
    volumes:
      - ./log:/var/log/mysql
      - ./lib:/var/lib/mysql
    labels:
      - "traefik.enable=false"

networks:
    web:
        external: true
    internal:
        external: false

The .env file looks like:

MYSQL_DATABASE=ghost
MYSQL_USER=ghost
MYSQL_PASSWORD=<your-top-secret-mysql-password>
MYSQL_ROOT_PASSWORD=<your-even-more-top-secret-root-password>


The config.production.json should be a json blob like this:

{
  "url": "https://blog.your-domain.com",
  "server": {
    "port": 2368,
    "host": "0.0.0.0"
  },
  "database": {
    "client": "mysql",
    "connection": {
    "host": "db",
    "port": 3306,
    "user": "ghost",
    "password": "<ghost-db-password-you-set-in-docker-compose>",
    "database": "ghost"
  }
},
"mail": {
    "transport": "SMTP",
    "options": {
      "service": "Sendgrid",
      "host": "smtp.sendgrid.net",
      "port": "587",
      "auth": {
        "user": "apikey",
        "pass": "<sendgrid-api-key>"
      }
    }
  },
  "logging": {
    "transports": [
      "file",
      "stdout"
    ]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/lib/ghost/content"
  },
  "admin": {
    "url": "https://blog-admin.your-domain.com"
  }
}


Once you have these files in place, docker compose up -d to bring up the container!

Login to your admin interface via https://blog-admin.your-domain.com/ghost and you are off to the races.

Good luck!