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

> 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.

Source: https://sandboxer.com/guides/run-ghost-blog-in-a-sandbox. Updated 2026-10-27.

**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](https://ghost.org/docs/) 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](https://hub.docker.com/_/ghost) and sets Ghost's [configuration](https://docs.ghost.org/config) with environment variables, where `__` stands for a nested key:

```yaml
# 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:

```bash
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
```

```text
{"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:

```bash
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}}'"
```

```text
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:

```bash
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/'
```

```text
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](https://docs.ghost.org/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:

```bash
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/
```

```text
{"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:

```text
{"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](https://docs.ghost.org/config#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:

```text
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](https://docs.ghost.org/admin-api#token-authentication) wants a short-lived HS256 JWT signed with the secret:

```python
# 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.

```bash
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"
```

```text
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:

```text
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.**

```bash
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`:

```text
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

```bash
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](https://docs.ghost.org/faq/supported-databases) 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](https://ghost.org/help/imports/) 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

- [Run WordPress in a sandbox](/guides/run-wordpress-in-a-sandbox)
- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Run MySQL or MariaDB in a sandbox](/guides/run-mysql-or-mariadb-in-a-sandbox)
- [Hosting](/docs/hosting)
