Back to Blog
Tutorial7 min read2026-07-11

How to Deploy Ghost with MySQL 8 in a Container

Self-host Ghost properly: the official Docker image, MySQL 8 wired via DATABASE_URL, the url config trap, image storage, and SMTP — end to end.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Ghost is one of the most pleasant pieces of software to self-host — a single Node.js process, migrations that run themselves, and a first-boot experience that just works. But it has strong opinions, and deploying it in a container means respecting three of them: MySQL 8 is the only supported production database, the url config must exactly match your domain, and uploaded images live on the local filesystem unless you say otherwise. Get those right and Ghost is boring in the best way. Here's the full setup.

The non-negotiables

Before writing any config, know what Ghost requires:

  • Database: Ghost 5+ supports [only MySQL 8 in production](https://ghost.org/docs/faq/supported-databases/). SQLite is for local development, and PostgreSQL isn't supported at all. If your platform's reflex is "provision Postgres," override it.
  • Port: Ghost listens on 2368 by default.
  • Migrations: Ghost runs its own schema migrations (via knex-migrator) automatically at startup. First boot against an empty database creates the entire schema. There is no separate migrate step — which is convenient, with one caveat we'll get to.
  • Process model: Ghost is designed to run as a single instance. Don't set replicas to 3 and expect it to be happy — it's a CMS, not a stateless API.

The Dockerfile

The official [ghost image](https://ghost.org/docs/install/docker/) on Docker Hub is well maintained, so the Dockerfile is short. The only thing we add is a small startup shim (next section):

FROM ghost:5

ENV NODE_ENV=production

COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh

EXPOSE 2368
CMD ["start.sh"]

The base image already knows how to run Ghost (node current/index.js from /var/lib/ghost); our shim just prepares the environment first.

Wiring MySQL: from DATABASE_URL to Ghost's config

Ghost doesn't read a DATABASE_URL. It reads nested configuration through environment variables with a double-underscore convention:

database__client=mysql
database__connection__host=...
database__connection__port=3306
database__connection__user=...
database__connection__password=...
database__connection__database=...

Managed platforms, on the other hand, typically inject a single connection string. The clean fix is a startup script that splits DATABASE_URL into the variables Ghost wants — Node is already in the image, so use it as the parser:

#!/bin/sh
set -e

if [ -n "$DATABASE_URL" ]; then
  export database__client=mysql
  export database__connection__host="$(node -e 'console.log(new URL(process.env.DATABASE_URL).hostname)')"
  export database__connection__port="$(node -e 'console.log(new URL(process.env.DATABASE_URL).port || 3306)')"
  export database__connection__user="$(node -e 'console.log(decodeURIComponent(new URL(process.env.DATABASE_URL).username))')"
  export database__connection__password="$(node -e 'console.log(decodeURIComponent(new URL(process.env.DATABASE_URL).password))')"
  export database__connection__database="$(node -e 'console.log(new URL(process.env.DATABASE_URL).pathname.slice(1))')"
fi

exec docker-entrypoint.sh node current/index.js

Save that as start.sh next to the Dockerfile. The decodeURIComponent calls matter — generated database passwords often contain characters that get percent-encoded in the URL, and passing the encoded form straight to MySQL gives you an authentication error that's miserable to debug.

The caveat mentioned earlier: because migrations run at boot, the very first deploy must be a single instance against the empty database. Since Ghost is single-instance by design anyway, this is only a trap if you've configured autoscaling out of habit. Don't.

The url config trap

The second-most-common Ghost deployment bug after database auth is this: the site loads, but assets 404, redirects bounce to the wrong host, or everything redirects in a loop. That's the url setting.

Ghost builds every absolute link — canonical URLs, image paths, redirects — from one config value:

url=https://blog.example.com

It must be the exact public URL, scheme included. If you're on https behind a proxy (you almost certainly are), Ghost trusts the X-Forwarded-Proto header to know the original request was secure — any sane platform ingress sets this, but if you ever see a redirect loop, this is where to look.

Set url to your real custom domain from day one. Changing it later works, but content you created while it pointed at a temporary domain can retain absolute links to that domain.

Image uploads: the ephemeral filesystem problem

Ghost writes uploaded images to content/images inside /var/lib/ghost/content. In a container, that directory is wiped on every redeploy — so two weeks in, someone redeploys and every post's cover image is a broken link.

You have two workable options:

  1. 1A persistent volume mounted at /var/lib/ghost/content, if your platform supports attaching one to the service. This also preserves themes you upload through the admin.
  2. 2A storage adapter that pushes images to S3-compatible object storage. Ghost supports [custom storage adapters](https://ghost.org/docs/config/#creating-a-custom-storage-adapter); community adapters for S3 are widely used. Images survive any redeploy and get served from the bucket or a CDN in front of it.

Either is fine. Doing neither is the mistake.

Mail: not optional if you use Members

Ghost's Members feature — signups, magic-link logins, staff invites — sends email, and without a transport configured those flows silently fail. Configure SMTP through the same env var convention:

mail__transport=SMTP
mail__options__host=smtp.yourprovider.com
mail__options__port=587
mail__options__auth__user=postmaster@mg.example.com
mail__options__auth__pass=<smtp password>
mail__from=hello@blog.example.com

Note the distinction Ghost draws: transactional email (the above) can use any SMTP provider, but bulk newsletters specifically require Mailgun, configured separately in the admin under Settings → Email newsletter. If you're not sending newsletters, SMTP alone covers you.

Deploying on PandaStack

Putting it together on PandaStack:

  1. 1Provision a managed MySQL 8.x database from the dashboard. (MySQL 5.7 is also available — for Ghost, choose 8.x, since it's the only version Ghost supports.)
  2. 2Connect your repo — just the Dockerfile and start.sh above — as a container app. The Dockerfile is detected and built in an isolated BuildKit pod, with build logs streaming live so you can watch the image assemble.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically, and the startup shim translates it into Ghost's database__* variables. No credentials copied by hand.
  4. 4Set the remaining env vars in the dashboard: url, the mail__* block, and NODE_ENV=production if you want it explicit.
  5. 5Add your custom domain — SSL is automatic — and make sure it matches url exactly.
  6. 6Push. First boot runs the schema migrations, then visit https://blog.example.com/ghost/ to create your admin account. Do that promptly; the setup screen is open until the first account exists.

A note on tiers: on the free tier, idle apps scale to zero and cold-start on the next visit. For a personal blog that's a reasonable trade for $0. For a publication where every reader's first paint matters, run it on a paid tier where the instance stays warm — and note that daily database backups are retained 7 days on Free, 15 on Pro, 30 on Premium, which for a CMS holding years of writing is worth a glance.

The five-minute checklist

MySQL 8 (not Postgres, not SQLite), port 2368 exposed, url matching your real domain, images on a volume or storage adapter, SMTP configured, single instance. That's the whole game — Ghost handles the rest itself.

If you'd rather not hand-assemble the database and wiring, the same setup takes a few minutes on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also