I deployed Ghost on Dokploy last week. The default docker-compose.yml you find in tutorials works, but it has root DB credentials, plaintext passwords, HTTP URLs, and no mail config. Here's what I changed to make it production-ready.
The Starting Point
The standard compose file looks like this:
version: "3.8"
services:
ghost:
image: ghost:6-alpine
restart: always
environment:
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: example
database__connection__database: ghost
url: http://${GHOST_HOST}
volumes:
- ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
- db:/var/lib/mysql
volumes:
ghost:
db:
Three problems jump out immediately:
- Ghost connects as MySQL
rootwith passwordexample urlis HTTP — Ghost won't set secure cookies or generate correct links- No mail config means no password resets, no member invites, no newsletter sends
Fix 1: Create a Dedicated DB User
Never run an app as database root. MySQL lets you create a restricted user at container init:
db:
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost_user
MYSQL_PASSWORD: ${DB_PASSWORD}
Then point Ghost at that user:
ghost:
environment:
database__connection__user: ghost_user
database__connection__password: ${DB_PASSWORD}
database__connection__database: ghost
Key detail: MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE only run on first container start. If the db volume already exists, MySQL ignores them and keeps the old credentials. On a fresh deploy, delete the volume first (Dokploy UI → Volumes → Delete db) so the new credentials take effect.
Fix 2: Use HTTPS in the URL
Dokploy's Traefik handles SSL termination, but Ghost needs to know it's behind HTTPS:
url: https://${GHOST_HOST}
Without this, you'll get mixed-content warnings, login redirects to HTTP, and broken password-reset links.
Fix 3: Add Mail (Required for Members and Newsletters)
Ghost won't send any email without SMTP configured. Minimum config:
mail__transport: SMTP
mail__options__host: ${SMTP_HOST}
mail__options__port: 587
mail__options__auth__user: ${SMTP_USER}
mail__options__auth__pass: ${SMTP_PASS}
mail__from: ${SMTP_USER}
Providers I've tested:
- Mailgun — host
smtp.mailgun.org, port 587, user ispostmaster@mg.yourdomain.com - SendGrid — host
smtp.sendgrid.net, port 587, user is literallyapikey, pass is your SendGrid API key - Postmark — host
smtp.postmarkapp.com, port 587, user and pass are both your Server API Token
Fix 4: Move Secrets Out of the Compose File
Dokploy has an Environment Variables tab per project. Use it instead of hardcoding.
# In compose — reference only
database__connection__password: ${DB_PASSWORD}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
mail__options__auth__pass: ${SMTP_PASS}
# In Dokploy UI — actual values
GHOST_HOST=yourdomain.com
DB_PASSWORD=9MUu3K801sUyOUxQ6HraDj4O82KKL94P
MYSQL_ROOT_PASSWORD=uwOp5XPvyNN93fL9CIUDzFGvzEUYoj8K
SMTP_HOST=smtp.mailgun.org
SMTP_USER=postmaster@mg.yourdomain.com
SMTP_PASS=your-smtp-password
Click the lock icon next to each secret value in Dokploy so they're masked in logs and the UI.
Fix 5: Isolate the Database on an Internal Network
The DB doesn't need internet access. Only Ghost should talk to it.
services:
ghost:
networks:
- internal
- web # Traefik attaches here
db:
networks:
- internal # no external access
networks:
internal:
internal: true
web:
external: true
internal: true means no outbound connectivity from that network. The database literally cannot reach the internet.
The Final Compose
version: "3.8"
services:
ghost:
image: ghost:6-alpine
restart: always
networks:
- internal
- web
environment:
database__client: mysql
database__connection__host: db
database__connection__user: ghost_user
database__connection__password: ${DB_PASSWORD}
database__connection__database: ghost
url: https://${GHOST_HOST}
mail__transport: SMTP
mail__options__host: ${SMTP_HOST}
mail__options__port: 587
mail__options__auth__user: ${SMTP_USER}
mail__options__auth__pass: ${SMTP_PASS}
mail__from: ${SMTP_USER}
volumes:
- ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
networks:
- internal
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost_user
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db:/var/lib/mysql
volumes:
ghost:
db:
networks:
internal:
internal: true
web:
external: true
Generating Strong Passwords
# Run twice — once for DB_PASSWORD, once for MYSQL_ROOT_PASSWORD
openssl rand -base64 32
Example output: 9MUu3K801sUyOUxQ6HraDj4O82KKL94P. Paste both into Dokploy's env vars. Done.
One Gotcha: Image Tags
ghost:6-alpine does not auto-update. It pins to the 6.x series — you get patch and minor updates within v6 when you redeploy, but it never jumps to v7. That's usually what you want. To upgrade, change the tag in compose and redeploy.
Verifying It Works
After deploy, check logs for:
Ghost database ready in Xs— DB connectedGhost booted in Xs— app started cleanly- No
ER_ACCESS_DENIED_ERRORorECONNREFUSED
Then visit https://yourdomain.com/ghost — first visit shows the admin setup screen. Create your owner account. That's it.
FAQ
Can I use SQLite instead of MySQL?
Only for local dev. SQLite doesn't handle concurrent writes — Ghost Admin and site traffic will lock the database.
My domain shows "Coming soon" after deploy.
Ghost creates a default draft post. Go to /ghost, sign in, and publish or delete it.
How do I back up?
Two parts: database — mysqldump via cron to S3. Content volume — docker run --rm -v ghost_content:/data -v $(pwd):/backup alpine tar czf /backup/ghost-$(date +%F).tar.gz /data. Automate both.
What if I change DB credentials later?
You'll break member login and newsletters because encryption keys are tied to the database. If you must change them, migrate data properly or accept re-onboarding members.
Written by Faisal | Self-hosting Ghost on Dokploy