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:2368keeps Ghost off the sandbox's public IPv6 address. The hosted URL reaches it through--localhost.contentholds uploaded images and themes. Posts, users and settings live in MySQL.security__staffDeviceVerificationis 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/tcpUse 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: 200The 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: 201The 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: 500Ghost 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.icoWith 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 ssbx 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 secondsThe 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 --yesAfterwards 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.
Related#
More guides
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.
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.
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.
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.