How I Moved To Ghost 3 (And Made Ghost Dawn Dark-Mode Only)
5 min read

How I Moved To Ghost 3 (And Made Ghost Dawn Dark-Mode Only)

Story of how I set up Ghost 3 on Docker Swarm behind nginx and with own mail server.
How I Moved To Ghost 3 (And Made Ghost Dawn Dark-Mode Only)

Back in January 2019 I deployed self-hosted Ghost 2 at my home PC server. It was almost 2 years ago, I stopped writing posts to my Ghost blog in April 2019 despite lots of solutions for different projects that would help people on the Internet if they were documented. So now I decided to resurrect this blog and migrate it (and all other stuff on home servers) to Contabo VPS.

TL;DR: for quick Ghost 3 setup check here.



My Ghost deployment lives behind nginx proxy and uses Postfix for mail. I happened to create simple management CLIs for both :)

PyPI links: webserver and kolombo (both rely on Docker)

You can use your solutions nginx and mail server, nginx part will be explained in details and for mail server - you just need it to exist somewhere.

Setting up nginx

Before we begin, start webserver via webserver run. This will start default HTTP server on 80 that will help us to get LetsEncrypt TLS certificates.

Blocks FYI will help you set it up without webserver - just make sure you have default HTTP server on 80 running via nginx in Docker with attachable overlay network "nginx" created and attached to container.

When you see domain in bold below - replace it with your domain.

Generating certificates and DH params

With webserver this is easy - just run commands below and it will generate LetsEncrypt TLS certificates and DH params needed for HTTPS configuration.

webserver gentls
webserver gendh

FYI: Commands above use certbot and openssl (use them directly if not using webserver):

sudo certbot certonly --webroot -w /path/to/http/root/ \
    --post-hook "nginx -t && nginx -s reload" \

sudo openssl dhparam \
    -out /path/to/dhparams/ 3072

Underlined paths must exist and must be accessible to nginx:

  • /path/to/http/root/ is accessed when you go to
  • /path/to/dhparams/ is where DH params are stored for nginx to use

Adding HTTPS config for nginx

After that we can finally add HTTPS config for nginx (but not yet reload nginx). Config content should be similar to the one I generated for webserver, add it via webserver conf add file.conf:

# Config used with webserver
server {

#### Set up SSL/TLS
    include /etc/nginx/sites-enabled/defaults/ssl;
    ssl_certificate /etc/letsencrypt/live/;
    ssl_certificate_key /etc/letsencrypt/live/;
    ssl_dhparam /path/to/dhparams/;

#### Set body limit to 4 mbytes (for pictures and lots of text)
    client_max_body_size 4m;

    location / {
        include /etc/nginx/sites-enabled/defaults/proxy;
        proxy_pass http://blog:2368/;

FYI: Expanded version of config above (add it yourself if not using webserver):

server {

#### Set up SSL/TLS
    #### Use HTTP/2 and modern TLS versions
    listen 443 ssl http2;
    ssl_protocols TLSv1.2 TLSv1.3;

    #### Do not send nginx version
    server_tokens off;

    #### Use strong, PFS and TLSv1.3 compatible ciphers and let user choose between them
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve secp384r1;

    #### Reuse SSL session parameters to avoid SSL handshakes for parallel and subsequent connections
    ssl_session_timeout 1h;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    #### Security headers (for browsers)
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    ssl_certificate /etc/letsencrypt/live/;
    ssl_certificate_key /etc/letsencrypt/live/;
    ssl_dhparam /path/to/dhparams/;

#### Set body limit to 4 mbytes (for pictures and lots of text)
    client_max_body_size 4m;

    location / {
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Early-Data $ssl_early_data;

        proxy_pass http://blog:2368/;

We will reload nginx later, for now we will leave it.

You can see that blog in bold on proxy_pass line - it is the hostname that will become accessible after we attach network "nginx" (mentioned above) to Ghost service.

Setting up Postfix

Essentially you just need to set up any mail server. In this tutorial I will set it up with kolombo (link from above) in those few easy steps:

# Generate valid TLS certificates for domain
webserver gentls

# Setup kolombo
kolombo setup
nano /etc/kolombo/kolombo.env

# Add mail domain
kolombo domain add
# [...] Deal with DNS TXT record for DKIM

# Add user for emails from blog
kolombo user add
# Re-build and deploy kolombo
kolombo run all --build

Boom, mail server is set up and sending should be possible via over secure connection.

Deploying Ghost 3

Files used can be cloned from my ghost-docker repository.

For deployment I use Docker with Swarm mode, so swarm should be initialized:

sudo docker swarm init


Create a file stack.yml that will contain Docker Swarm configuration for your Ghost 3 deployment:

version: '3.7'
    image: ghost:3-alpine
      - 2368:2368
      mail__from: "'Ghost Blog' <>"
      mail__transport: SMTP
      mail__options__port: 587
      mail__options__secureConnection: "true"
      mail__options__auth__pass: actualPassword
      - /home/actualUser/ghost_content:/var/lib/ghost/content
    hostname: blog
      - nginx
        delay: 5s
        failure_action: rollback
        condition: any
          cpus: '1'
          memory: 750M

    external: true

I underlined things that you should change. Some things to mention:

  • Volume folder should already exist when deploying, Docker Swarm will not create it.
  • Network nginx should already exist when deploying, if you are using webserver - it is created, otherwise - create it via docker network create -d overlay --attachable nginx
  • In case your mail server does not use secure connection - you can remove mail__options__secureConnection.
  • That hostname: blog part is there so nginx can access Ghost container by hostname when they are in one network.


To deploy it you just need to run one command:

sudo docker stack deploy -c stack.yml ghost

Docker Swarm will take care of everything else. Now we can finally reload nginx for new HTTPS configuration to be enabled:

# If using webserver
webserver conf reload

# If nginx is in some container
sudo docker exec -it nginx-container sh -c 'nginx -t && nginx -s reload'

Voila! Ghost 3 blog should be on One drawback - admin panel can be accessed by anybody from this point, so don't waste your time - set up admin user!


You will need to update the container image at some point. Update is done with this script:

set -e

# Scaling before update to keep availability
sudo docker service scale ghost_blog=2
# Updating to latest Ghost 3 Alpine Linux image
sudo docker service update --image ghost:3-alpine ghost_blog
# Scaling back
sudo docker service scale ghost_blog=1

Tweaking Ghost Dawn theme

As you can see - this theme has only Dark Mode (as it should be everywhere tbh). It was not always like that, Ghost Dawn can actually be in Light Mode, but I removed the switch and set Dark Mode when page loads.

  • Go to your /home/actualUser/ghost_content/themes/Dawn-master
  • Open default.hbs in editor and apply change below

Remove <script> tag with switch-case block and make sure first tag contains following code (new code is underlined):

var siteUrl = '{{@site.url}}';
// Force dark mode
localStorage.setItem('dawn_theme', 'dark');
  • Open partials/footer.hbs and apply change below

Remove <a> tag in the end inside last <nav class="footer-nav"> so that it looks like below:

<nav class="footer-nav">
    {{navigation type="secondary"}}

Restart Ghost container and you should have Dark Mode by default without a way to switch it to Light Mode!

sudo docker service scale --detach ghost_blog=0
sudo docker service scale ghost_blog=1

Happy Experimenting!