Skip to content
Guides · Recipes

How to Run a Ghost Blog in a Sandbox Behind an HTTPS URL

Updated October 27, 2026 .md

Short answer: Host port 2368 with sbx host first, then start the ghost and mysql images with Docker Compose and the hosted HTTPS URL as Ghost's url. Turn off staff device verification unless you configure mail, or a second login fails. Publish from a laptop with an Admin API key plus the __sbx_auth cookie.

Ghost is a Node.js publishing platform: a themed public site, an admin app at /ghost/ and two REST APIs. Behind a proxy it cares about three things: the url it is configured with, the X-Forwarded-Proto header, and the Origin header on admin requests. On a fresh install it also emails a code whenever staff sign in from a new device. This page shows what each of those did behind a Sandboxer URL.

What we ran#

Everything below ran on production on October 27, 2026, in one small sandbox (2 vCPU, 4 GB RAM, region eu-central) with Ghost 6.72.0 from the ghost:6-alpine image and MySQL 8.4. Requests marked "laptop" came from our test machine. Times are single runs unless noted.

Step Result
sbx new --type small 3.2 s to ready
First start (Ghost creates its tables) Ghost logged Ghost booted in 7.431s
Memory after start Ghost 228 MB, MySQL 479 MB
Publish a post with the Admin API, laptop 201 in 0.35 s
Post page through the hosted URL, laptop, 20 requests 0.45 s median (0.44 to 0.47 s)
Same page inside the sandbox on localhost:2368 28 ms median
Recreate the Ghost container after a config change serving again in 3.4 s
sudo systemctl restart docker serving again in 7.6 s, Ghost restarted once
sbx stop --wait, then sbx resume 2.2 s and 1.3 s, memoryRestored: true, still logged in
Resume with --type default (boots from disk) post page 200 about 3 s after resume

1. The Compose file#

Docker and Compose are preinstalled in every sandbox. This file uses the Docker Official Image for Ghost and sets Ghost's configuration with environment variables, where __ stands for a nested key:

# ghost/compose.yaml
services:
  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 5s
      retries: 30

  ghost:
    image: ghost:6-alpine
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:2368:2368"
    environment:
      url: ${GHOST_URL}
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ${DB_PASSWORD}
      database__connection__database: ghost
      security__staffDeviceVerification: "false"
    volumes:
      - content:/var/lib/ghost/content

volumes:
  db:
  content:
  • 127.0.0.1:2368:2368 keeps Ghost off the sandbox's public IPv6 address. The hosted URL reaches it through --localhost.
  • content holds uploaded images and themes. Posts, users and settings live in MySQL.
  • security__staffDeviceVerification is explained in step 4. We started without it.

2. Host the port first, then start Ghost with that URL#

Ghost builds every link, redirect and image URL from url, and it checks admin requests against it. So get the hosted URL before the first start:

sbx new --name ghost --type small
id=sb_o07601pv35    # the id that sbx new printed; its subdomain was misty-silent-eel

sbx host $id 2368 --localhost --title ghost
{"ok":true,"type":"port.hosted","sandboxId":"sb_o07601pv35","port":2368,"url":"https://misty-silent-eel-2368.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":true}

Hosting a port before anything listens on it is fine. Now copy the folder in, write the database password and the URL to .env (the password is never printed), and start the stack:

sbx scp -r ./ghost $id:/home/user/
sbx exec $id --timeout 600 "cd ghost && umask 077 \
  && printf 'DB_PASSWORD=%s\nGHOST_URL=%s\n' \$(openssl rand -hex 16) https://misty-silent-eel-2368.box.sandboxer.com > .env \
  && docker compose up -d --quiet-pull \
  && docker compose ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
SERVICE   IMAGE            STATUS                    PORTS
db        mysql:8.4        Up 18 seconds (healthy)   3306/tcp, 33060/tcp
ghost     ghost:6-alpine   Up 7 seconds              127.0.0.1:2368->2368/tcp

Use the base URL in GHOST_URL, without ?_token=.

3. Why the URL must be https, and what the proxy sends#

With an https url, Ghost redirects any request that doesn't look like HTTPS. TLS ends at the Sandboxer edge, so Ghost only knows the request was HTTPS from the X-Forwarded-Proto header. Inside the sandbox:

sbx exec $id 'curl -s -o /dev/null -w "plain http: %{http_code} -> %{redirect_url}\n" localhost:2368/
curl -s -o /dev/null -w "with XFP: %{http_code}\n" -H "X-Forwarded-Proto: https" \
  -H "Host: misty-silent-eel-2368.box.sandboxer.com" localhost:2368/'
plain http: 301 -> https://localhost:2368/
with XFP: 200

The hosting proxy keeps the original Host and sends X-Forwarded-Proto: https, so through the hosted URL every page answered 200 and every link, image and icon URL used https://misty-silent-eel-2368.box.sandboxer.com. No Ghost proxy setting was needed. Two consequences for scripts inside the sandbox: send those two headers when you call localhost:2368, and don't treat a 301 there as Ghost being down.

4. Create the owner, and the login that fails without mail#

A new Ghost has no owner, and whoever reaches /ghost/ first creates one. The private URL keeps strangers out, but set the owner up right away. We did it with the Admin API from the laptop. $B is the base URL, $T the URL's token, and setup.json and login.json hold the name, email and a random password:

C="Cookie: __sbx_auth=$T"
curl -s -H "$C" $B/ghost/api/admin/authentication/setup/
curl -s -o /dev/null -w "setup: %{http_code}\n" -H "$C" -H "Origin: $B" \
  -H "Content-Type: application/json" -d @setup.json $B/ghost/api/admin/authentication/setup/
curl -s -c jar -o /dev/null -w "session: %{http_code}\n" -H "$C" -H "Origin: $B" \
  -H "Content-Type: application/json" -d @login.json $B/ghost/api/admin/session/
{"setup":[{"status":false}]}
setup: 201
session: 201

The first sign-in worked. A second sign-in with an empty cookie jar, as from a new browser, did not:

{"errors":[{"message":"Failed to send email. Please check your site configuration and try again.","type":"EmailError",…,"code":"ESOCKET",…}]}
session2: 500

Ghost 6 sends a verification code by email when staff sign in from a device it hasn't seen. The image's defaults turn this on ("staffDeviceVerification": true in defaults.json), and a sandbox has no mail server. Either configure mail or turn the check off, as the Compose file above does. After docker compose up -d ghost recreated the container with that setting, a sign-in from a fresh cookie jar returned 201. Turning it off removes a second factor, which is acceptable for a private test site. For a public site, configure mail instead.

Ghost also checks Origin on cookie-authenticated admin requests:

Origin: http://localhost:2368  ->  400 "Request made from incorrect origin. Expected 'https://misty-silent-eel-2368.box.sandboxer.com' received 'http://localhost:2368'."

A browser on the hosted URL sends the right origin by itself. If an admin app or tool calls Ghost from another origin, that is the error you'll see.

5. Publish from a laptop with an Admin API key#

For scripts, use an integration key instead of the staff session. We created one with the session (POST /ghost/api/admin/integrations/?include=api_keys with {"integrations":[{"name":"laptop"}]}), which returns a Content API key and an Admin API key. The Admin API key has the form <id>:<hex secret>. Ghost's token authentication wants a short-lived HS256 JWT signed with the secret:

# jwt.py: prints a 5-minute Ghost Admin API token from the key in admin.key
import sys, time, hmac, hashlib, base64, json
kid, secret = open(sys.argv[1]).read().strip().split(':')
b = lambda x: base64.urlsafe_b64encode(x).rstrip(b'=').decode()
now = int(time.time())
h = b(json.dumps({"alg": "HS256", "typ": "JWT", "kid": kid}).encode())
p = b(json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}).encode())
s = b(hmac.new(bytes.fromhex(secret), f"{h}.{p}".encode(), hashlib.sha256).digest())
print(f"{h}.{p}.{s}")

The request needs both gates: the __sbx_auth cookie for the Sandboxer proxy and Authorization: Ghost <jwt> for Ghost. The proxy removes its cookie and passes Authorization through.

JWT=$(python3 jwt.py admin.key)
curl -s -o post.json -w "post: %{http_code} %{time_total}s\n" -H "$C" -H "Authorization: Ghost $JWT" \
  -H "Content-Type: application/json" \
  -d '{"posts":[{"title":"Hello from a sandbox","status":"published","html":"<p>Posted from my laptop through the hosted URL.</p>"}]}' \
  "$B/ghost/api/admin/posts/?source=html"
curl -s -H "$C" "$B/ghost/api/content/posts/?key=$(cat content.key)&fields=title,url"
post: 201 0.348730s
{"posts":[{"title":"Hello from a sandbox","url":"https://misty-silent-eel-2368.box.sandboxer.com/hello-from-a-sandbox/"},{"title":"Coming soon","url":"…/coming-soon/"}],"meta":{…}}

Who answered which request tells you which gate failed:

Request Status From
JWT, no __sbx_auth cookie 401, HTML page Sandboxer proxy
Cookie, no JWT 403, "Authorization failed" JSON Ghost
Cookie and JWT 201 Ghost

Image uploads work the same way (POST /ghost/api/admin/images/upload/ with -F file=@cover.png). The image came back as https://misty-silent-eel-2368.box.sandboxer.com/content/images/2026/10/cover.png and served 200 through the URL. Site settings such as the icon need a staff session: the integration key got 403 there.

6. Ghost calls its own URL, and a private URL says 401#

Ghost's log filled with this, once per page render:

ERROR Unknown Request error.
Error Code: IMAGE_SIZE_URL
HTTPError: Request failed with status code 401 (Unauthorized): GET https://misty-silent-eel-2368.box.sandboxer.com/favicon.ico

With no site icon set, Ghost measures the default favicon by fetching it from its own public URL. That request has no __sbx_auth cookie, so the proxy refuses it. Pages still returned 200. After we uploaded an icon and set it in Settings (as staff, PUT /ghost/api/admin/settings/ with the icon key), Ghost read it from disk and the errors stopped across four more page loads. At start, Ghost also logged ActivityPub is unavailable (401) - skipping webhook setup. We didn't test ActivityPub or newsletters, which need a public URL and mail.

7. Restarts, stop and resume#

Docker restart. sudo systemctl restart docker stopped both containers. restart: unless-stopped brought them back and the post page answered again 7.6 s later. Ghost's restart count went to 1: Compose's service_healthy condition applies only to docker compose up, so after the daemon restart Ghost started before MySQL, logged connect ECONNREFUSED …:3306, exited and came back. Posts, the uploaded image and our admin session all survived.

Stop and resume.

sbx stop $id --wait     # 2.2 s
sbx resume $id          # 1.3 s

sbx info showed memoryRestored: true. The containers kept running, the same cookie jar still opened /ghost/api/admin/users/me/, and the URL and token were unchanged.

Boot from disk. A resume boots from disk when you resize or no machine with the same CPU type is free. We forced it with sbx resume $id --type default:

default memoryRestored False resized
8.95 15.58
/ghost-ghost-1 restarts=1
db Up 7 seconds (healthy)
ghost Up 3 seconds

The second line is /proc/uptime: a fresh boot. For about two seconds the URL returned 503 while Ghost was starting (11:59:08 UTC). Then the post page returned 200, and the posts, image and admin session were all there. Ghost again restarted once while MySQL came up.

8. Clean up#

sbx delete $id --yes

Afterwards sbx info returned not_found and the URL returned 404. A small sandbox costs $0.018/hour while it runs and nothing while it is stopped. Ghost and MySQL used about 700 MB of its 4 GB.

FAQ#

Why does Ghost redirect me to localhost or loop on redirects?#

Its url doesn't match the address you opened, or the request didn't arrive as HTTPS. Set url to the hosted HTTPS URL without ?_token= and recreate the container. The Sandboxer proxy sends X-Forwarded-Proto: https, so no other proxy setting is needed.

Why does sign-in fail with "Failed to send email"?#

Ghost emails a code when staff sign in from a new device, and the sandbox has no mail server. Set security__staffDeviceVerification: "false" for a private test site, or configure mail.

Can I use SQLite instead of MySQL?#

Ghost's docs say MySQL 8 is the only supported database in production. Its SQLite setup is for development mode, which we didn't test. MySQL used 479 MB here, which fits in small.

Can I make the blog public?#

Yes: sbx host $id 2368 --localhost --public. Create the owner first, because anyone can then reach /ghost/. Switching from public back to private mints a new token.

Can I import an existing site?#

Ghost documents importing content in its Admin app. Images are separate files: copy them into the content volume under images. We didn't test an import.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide