Ghost mit Docker Compose, MySQL, Backup und Traefik als universelles Template betreiben

Ghost mit Docker Compose, MySQL, Backup und Traefik als universelles Template betreiben
Photo by Thought Catalog / Unsplash

Wer mehrere Webseiten oder Blogs mit Ghost betreibt, landet schnell bei wiederkehrenden docker-compose.yml-Dateien. Die Struktur ist meistens identisch: Ghost als Anwendung, MySQL als Datenbank, ein Backup-Container und Traefik als Reverse Proxy.

Damit nicht für jede neue Webseite die komplette Compose-Datei angepasst werden muss, lohnt sich ein universelles Template. Die eigentlichen Projektdaten wie Domain, Container-Namen, Datenbank-Zugangsdaten, SMTP-Server und Traefik-Router werden dabei über eine .env-Datei gesteuert.

Der Vorteil:
Die docker-compose.yml bleibt für alle Ghost-Instanzen gleich. Nur die .env unterscheidet sich je Projekt.

Ziel des Setups

Das Setup besteht aus drei Containern:

  1. app - Ghost-Blog
  2. db - MySQL-Datenbank
  3. backup - automatischer MySQL-Dump

Zusätzlich werden zwei Docker-Netzwerke verwendet:

  1. traefik - externes Netzwerk für den Reverse Proxy
  2. backend - internes Netzwerk zwischen Ghost, MySQL und Backup

Traefik übernimmt dabei die automatische Veröffentlichung der Webseite per HTTPS.

Verzeichnisstruktur

Eine mögliche Struktur auf dem Server sieht so aus:

/opt/docker/apps/
└── www.meineseite.de/
├── docker-compose.yml
└── .env

Die eigentlichen Daten der jeweiligen Webseite liegen zum Beispiel unter:

/opt/docker/volumes/www.meineseite.de/
├── ghost/
├── mysql/
└── backup/

Damit sind Konfiguration, Anwendungsdaten, Datenbank und Backups sauber voneinander getrennt.

Die universelle docker-compose.yml

Die folgende docker-compose.yml ist so aufgebaut, dass sie für unterschiedliche Ghost-Projekte wiederverwendet werden kann.

services:
  app:
    image: ${GHOST_IMAGE}
    container_name: ${PROJECT_NAME}_ghost
    restart: ${RESTART_POLICY}
    depends_on:
      - db
    environment:
      url: ${GHOST_URL}

      database__client: mysql
      database__connection__host: db
      database__connection__user: ${MYSQL_USER}
      database__connection__password: ${MYSQL_PASSWORD}
      database__connection__database: ${MYSQL_DATABASE}

      mail__transport: SMTP
      mail__from: ${SMTP_FROM}
      mail__options__service: SMTP
      mail__options__host: ${SMTP_HOST}
      mail__options__port: ${SMTP_PORT}
      mail__options__auth__user: ${SMTP_USER}
      mail__options__auth__pass: ${SMTP_PASSWORD}

    volumes:
      - ${DATA_PATH}/ghost:/var/lib/ghost/content

    networks:
      - traefik
      - backend

    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=${TRAEFIK_NETWORK}"

      # HTTPS Router
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}-secure.entrypoints=${TRAEFIK_HTTPS_ENTRYPOINT}"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}-secure.rule=${TRAEFIK_HOST_RULE}"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}-secure.tls=true"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}-secure.tls.certresolver=${TRAEFIK_CERTRESOLVER}"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}-secure.service=${TRAEFIK_SERVICE_NAME}"

      # HTTP zu HTTPS Redirect
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}.entrypoints=${TRAEFIK_HTTP_ENTRYPOINT}"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}.rule=${TRAEFIK_HOST_RULE}"
      - "traefik.http.routers.${TRAEFIK_ROUTER_NAME}.middlewares=${TRAEFIK_REDIRECT_MIDDLEWARE}"

      # Service Definition
      - "traefik.http.services.${TRAEFIK_SERVICE_NAME}.loadbalancer.server.port=${GHOST_INTERNAL_PORT}"

      # Middleware
      - "traefik.http.middlewares.${TRAEFIK_REDIRECT_MIDDLEWARE}.redirectscheme.scheme=https"
      - "traefik.http.middlewares.${TRAEFIK_REDIRECT_MIDDLEWARE}.redirectscheme.permanent=true"

  db:
    image: ${MYSQL_IMAGE}
    container_name: ${PROJECT_NAME}_mysql
    restart: ${RESTART_POLICY}
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
    volumes:
      - ${DATA_PATH}/mysql:/var/lib/mysql
      - ${DATA_PATH}/backup:/backup
    networks:
      - backend

  backup:
    image: ${MYSQL_BACKUP_IMAGE}
    container_name: ${PROJECT_NAME}_backup
    user: "0"
    restart: ${RESTART_POLICY}
    depends_on:
      - db
    networks:
      - backend
    environment:
      - DB_DUMP_TARGET=/backup
      - DB_USER=${BACKUP_DB_USER}
      - DB_PASS=${BACKUP_DB_PASSWORD}
      - DB_SERVER=db
      - DB_PORT=3306
      - DB_DUMP_BEGIN=${BACKUP_BEGIN}
      - DB_DUMP_FREQ=${BACKUP_FREQ}
    command:
      - dump
    volumes:
      - ${DATA_PATH}/backup:/backup

networks:
  traefik:
    name: ${TRAEFIK_NETWORK}
    external: true

  backend:
    name: ${PROJECT_NAME}_backend
    external: false

Die passende .env-Datei

Die eigentliche Steuerung erfolgt über die .env-Datei. Hier werden Projektname, Domain, Datenbankzugänge, SMTP-Parameter und Traefik-Werte definiert.

# =============================================================================
# Projekt
# =============================================================================

PROJECT_NAME=www-meineseite-de
DATA_PATH=/opt/docker/volumes/www.meineseite.de
RESTART_POLICY=always

# =============================================================================
# Ghost
# =============================================================================

GHOST_IMAGE=ghost:latest
GHOST_URL=https://www.meineseite.de
GHOST_INTERNAL_PORT=2368

# =============================================================================
# MySQL
# =============================================================================

MYSQL_IMAGE=mysql:8.0
MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
MYSQL_USER=ghost
MYSQL_PASSWORD=CHANGE_ME_GHOST_PASSWORD
MYSQL_DATABASE=ghostdb

# =============================================================================
# Mail / SMTP
# =============================================================================

SMTP_FROM=noreply@meineseite.de
SMTP_HOST=mail.meineseite.de
SMTP_PORT=587
SMTP_USER=noreply@meineseite.de
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD

# =============================================================================
# Backup
# =============================================================================

MYSQL_BACKUP_IMAGE=databack/mysql-backup

BACKUP_DB_USER=root
BACKUP_DB_PASSWORD=CHANGE_ME_ROOT_PASSWORD

# Start des ersten Backups:
# +1 = ca. 1 Minute nach Containerstart
BACKUP_BEGIN=+1

# Backup-Frequenz in Minuten:
# 1440 = täglich
BACKUP_FREQ=1440

# =============================================================================
# Traefik
# =============================================================================

TRAEFIK_NETWORK=traefik

TRAEFIK_ROUTER_NAME=ghost-meineseite
TRAEFIK_SERVICE_NAME=ghost-meineseite-svc

TRAEFIK_HTTP_ENTRYPOINT=web
TRAEFIK_HTTPS_ENTRYPOINT=websecure
TRAEFIK_CERTRESOLVER=hetzner

TRAEFIK_REDIRECT_MIDDLEWARE=redirect-to-https

TRAEFIK_HOST_RULE=Host(`www.meineseite.de`) || Host(`meineseite.de`)

Wichtige Variablen erklärt

PROJECT_NAME

Der Projektname wird für Container- und Netzwerknamen verwendet.

PROJECT_NAME=www-meineseite-de

Daraus entstehen zum Beispiel:

www-meineseite-de_ghost
www-meineseite-de_mysql
www-meineseite-de_backup
www-meineseite-de_backend

Ich verwende hier bewusst keine Punkte im Projektnamen. Das macht die Namen etwas robuster und besser lesbar.

DATA_PATH

Über DATA_PATH wird der Speicherort der persistenten Daten gesteuert.

DATA_PATH=/opt/docker/volumes/www.meineseite.de

Daraus ergeben sich automatisch:

/opt/docker/volumes/www.meineseite.de/ghost
/opt/docker/volumes/www.meineseite.de/mysql
/opt/docker/volumes/www.meineseite.de/backup

GHOST_URL

Diese Variable ist für Ghost besonders wichtig:

GHOST_URL=https://www.meineseite.de

Ghost verwendet diese URL intern für Links, Weiterleitungen, Assets und die Admin-Oberfläche. Der Wert sollte daher exakt der später genutzten öffentlichen URL entsprechen.

Datenbankzugang

Ghost verbindet sich intern über den Servicenamen db mit MySQL:

database__connection__host: db

Der Hostname muss also nicht angepasst werden. Variabel sind nur Benutzer, Passwort und Datenbankname:

MYSQL_USER=ghost
MYSQL_PASSWORD=CHANGE_ME_GHOST_PASSWORD
MYSQL_DATABASE=ghostdb

Der Backup-Container nutzt in diesem Beispiel den MySQL-Root-Benutzer:

BACKUP_DB_USER=root
BACKUP_DB_PASSWORD=CHANGE_ME_ROOT_PASSWORD

Das ist einfach und funktioniert zuverlässig. Wer es restriktiver möchte, kann später einen eigenen Backup-Benutzer mit passenden Rechten anlegen.

Traefik-Router

Die Labels in der Compose-Datei werden ebenfalls über Variablen gesteuert.

Für die produktive Webseite ist diese Regel entscheidend:

TRAEFIK_HOST_RULE=Host(`www.meineseite.de`) || Host(`meineseite.de`)

Damit reagiert der Container sowohl auf:

https://www.meineseite.de
https://meineseite.de

Der HTTPS-Router verwendet den konfigurierten Zertifikatsresolver:

TRAEFIK_CERTRESOLVER=hetzner

Das setzt voraus, dass Traefik bereits entsprechend eingerichtet ist und der externe Docker-Network-Name traefik existiert.

Voraussetzung: Traefik-Netzwerk

Da das Netzwerk traefik als extern definiert ist, muss es bereits existieren.

Prüfen kann man das mit:

docker network ls

Falls es noch nicht existiert, kann es so erstellt werden:

docker network create traefik

Das interne Backend-Netzwerk wird hingegen automatisch durch Docker Compose erzeugt.

Container starten

Das Projekt wird wie gewohnt gestartet:

docker compose --env-file .env up -d

Anschließend kann der Status geprüft werden:

docker compose ps

Die Logs der einzelnen Dienste lassen sich so anzeigen:

docker compose logs -f app
docker compose logs -f db
docker compose logs -f backup

Beispiel: zweite Ghost-Instanz

Der große Vorteil dieses Templates zeigt sich bei einer zweiten Webseite. Die docker-compose.yml bleibt unverändert. Nur die .env wird angepasst.

Beispiel:

PROJECT_NAME=blog-example-com
DATA_PATH=/opt/docker/volumes/blog.example.com

GHOST_URL=https://blog.example.com

MYSQL_ROOT_PASSWORD=NEUES_ROOT_PASSWORT
MYSQL_PASSWORD=NEUES_GHOST_DB_PASSWORT
SMTP_FROM=noreply@example.com
SMTP_USER=noreply@example.com
SMTP_PASSWORD=NEUES_SMTP_PASSWORT

TRAEFIK_ROUTER_NAME=ghost-example
TRAEFIK_SERVICE_NAME=ghost-example-svc
TRAEFIK_HOST_RULE=Host(`blog.example.com`)

Damit lässt sich mit derselben Compose-Datei eine weitere Ghost-Instanz betreiben, ohne die eigentliche Infrastrukturdefinition anzufassen.

Backup-Konzept

Der Container databack/mysql-backup erstellt regelmäßig Dumps der MySQL-Datenbank.

Im Beispiel startet das erste Backup kurz nach dem Containerstart:

BACKUP_BEGIN=+1

Die Frequenz ist auf täglich gesetzt:

BACKUP_FREQ=1440

Die Angabe erfolgt in Minuten.
1440 Minuten entsprechen 24 Stunden.

Die Backups landen im Verzeichnis:

/opt/docker/volumes/www.meineseite.de/backup

Ein Blick in das Verzeichnis zeigt, ob Dumps erzeugt werden:

ls -lah /opt/docker/volumes/www.meineseite.de/backup

Sicherheitshinweise

Die .env enthält sensible Daten wie Datenbank- und SMTP-Passwörter. Sie sollte daher nicht öffentlich in einem Git-Repository landen.

Sinnvoll ist eine .gitignore:

.env

Zusätzlich sollten produktive Passwörter ausreichend stark und eindeutig sein:

MYSQL_ROOT_PASSWORD=P;1kM~<K!/]J*~\vhg\=vb*WHhmq-}^,
MYSQL_PASSWORD=CdOsaWG?]1VqQxI&5@`}N\YJ+ik}vT[l
SMTP_PASSWORD=dTng+wo"B@-RkHXz?_`Elh]3ah45_i?O

Starke Passwörter können unter Linux z. B. mit dem Tool pwgenerzeugt werden:

❯ pwgen -s 32 -y
}hxL6nM+lOREc4~iPtF=#b@Ec6:L#.+y ,)ukp<bG]BpaH$7Ld\'My2oTw6y[#%G]
vsrY09B^O9H\/7v4-/b.&Y*XKb%/L^_, g0P]=eU6)U:MFp/$U![C7+$jfWd)b&TF
4Qoit*VBb/Fxxd'(D<M~Hps6:3yR5uz> $!^@SY0HyB|4fu@JgQMb(0^3ZTq/y'po
6C<88B)PGYF0b_#(9/d,L]R_^_|wr|U] *RHyMI(+tnfe%w@Ylm&&A3L`5Z70&kIB
>B%W,fk'oZ?g=f/t:B-!JP,4:Ku?&Z-C /|#-c5']dmmpE"Y7Rr6@46DCGA|52#3s
H7($(+0g[AI,QO91PtN~0t?5<cbXiTpU :cdBiY){s0jW0~#ggH0w#;8k3&EduUFq
>mRZXUXAW!z-\:yQ59Hi@k`$m}{DEtc. T$Ht(w>>+<w*UFQ=,P^3zMRG3A%44gKe
6+]u@wy)v{[@lp[GK%u3Tu=9{&,]4rKx AGcFZoAaWV&Q#Y;G5CM)3F!&sgpi~0h:
H6Y?rA.=3N!XX4|kDU{I#\(['P_'0/'` w?H,TENf8+&q12b:jTw9>_"zH#i*g:.q
gjD^m&xdu9Qv|j-3@PkY+;AENP@x"|_: P2WUPHcBqyBltKs[wm(ql[U0_<I*~O\(
^-dE;b972Bdad>2#gA&aJ44&UAJLZ1QS wU}W1IA/7JJ.qWKQTc<udoW:(91;Kel\
38YACxV1R(RggL<tmbN{;#k~~LFCHr@O tj\\y<+mu:._9^~t*RT"EG!>Ljc+kP:-
_l@*KaI/O""sVW4jZH<1,]aoGQ-s6DlD vl#'{X}>k`\9f`AjBY`8i?n>AvosVFFw
lw+(Lc</-zb{C9O7z+m5&$%5-{mebgwo {Pf?o|YG*534$d*ywdtWKRS,A%+L|!jr
=_HGY"tW~we$_+e@(!{M/-|G3mH))amO JFMK8kdps0\wMuN)Qzf?e(lI\Z</O@Pg
H9?HZ%4?%%6E#U%e>aMwKdRec?d{g!gz vGxkb"whcr|B7$F8=DKGC*?.9@Gz8rY2
2=~t%zM'e\[/Xpg<BHm!_/7O>#N]=Po. ~R`!r'|hZ_`;rHsDR+AS7"kjUZq(&Wnu
1M5+4%xsTb-n&"I|]@a\n!-4U99/ykb/ 4eHYFG.u(Q}$Q4cw]<x<JOFrT2k@6g0_
=pDS.|3>G|%15A%d[Fy(,&8{_&eiMraO /Zy3q"j+by>N];3%[I6Z.@YnW\t[!wp9
$#i9q=xp'C)1yHH|d%QR|1RkPyMdK_>k ^fWHX5=Jli0ds1aNO#]IXxG9XA0F5MKB

Für einfache Setups kann der Backup-Container mit dem MySQL-Root-Benutzer arbeiten. Für ein härter abgesichertes Setup wäre ein dedizierter Backup-Benutzer die bessere Variante.

Fazit

Mit diesem Template lässt sich Ghost sehr sauber und wiederverwendbar per Docker Compose betreiben. Die docker-compose.yml beschreibt nur noch die technische Struktur, während alle projektspezifischen Werte in der .env liegen.

Das macht den Betrieb mehrerer Ghost-Instanzen deutlich einfacher:

  • weniger Copy-and-Paste
  • weniger Fehler bei neuen Projekten
  • einheitliche Struktur
  • klare Trennung von Template und Konfiguration
  • einfache Anpassung von Domain, SMTP, Datenbank und Traefik

Für kleine und mittlere Blog-Setups ist das eine pragmatische und gut wartbare Lösung.