# How to Run Strapi in a Cloud Sandbox With a Private HTTPS URL

> Create a Strapi 5 app with create-strapi in a small Sandboxer sandbox, build the admin panel and run strapi start under systemd on 127.0.0.1:1337. Host it with sbx host --localhost. Set proxy.koa so login cookies are Secure. Clients send the __sbx_auth cookie plus a Strapi API token.

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

**Short answer:** Create a Strapi 5 app with create-strapi in a small Sandboxer sandbox, build the admin panel and run strapi start under systemd on 127.0.0.1:1337. Host it with sbx host --localhost. Set proxy.koa so login cookies are Secure. Clients send the __sbx_auth cookie plus a Strapi API token.

[Strapi](https://docs.strapi.io/cms/intro) is a Node.js headless CMS: an admin panel where editors fill in content, and a REST API that serves it. This page covers what is specific to Strapi behind a sandbox's hosted URL: a database path bug in the generated `.env`, the proxy setting that makes login cookies `Secure`, and the split between `develop` (schema changes) and `start` (production). For a generic Node setup, see the [Node.js dev environment](/guides/node-dev-environment-in-a-cloud-sandbox).

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, 12 GB disk, region `eu-central`) with Strapi 5.59.0 on the sandbox's Node 22.23.3, SQLite and the JavaScript template. Tokens and passwords are replaced with placeholders. Times are single runs unless the table says otherwise.

| Step | Result |
| - | - |
| `create-strapi` with `npm install` | about 2 minutes, 1,398 packages, 814 MB `node_modules` |
| `npm run build` (admin panel, production) | 35.1 s; memory in use peaked at 2.6 GB of 4 GB |
| `systemctl restart strapi` to a passing `/_health` | 3.2 s |
| Memory of `strapi start` | 240 MB resident |
| List articles through the hosted URL, from our test machine | median 195 ms over 20 calls (192 to 198 ms) |
| Same call inside the sandbox on `127.0.0.1:1337` | median 7.7 ms over 20 calls |
| `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, same PID, admin session still valid |
| Stop, then resume with `--type default` (boots from disk) | `/_health` answered through the URL 1.0 s after resume returned |

The build is the step that needs the RAM. `small` handled it, but nothing else heavy was running.

## 1. Create the app

`create-strapi` is Strapi's [own installer](https://docs.strapi.io/cms/installation/cli). These flags skip every prompt and the Strapi Cloud login:

```bash
sbx new --name strapi --type small
id=sb_cvah5xd2lv    # the id that sbx new printed; its subdomain was smooth-vital-sierra

sbx exec $id --detach "npx -y create-strapi@5.59.0 cms --non-interactive --skip-cloud --js --use-npm \
  --install --no-example --no-git-init --dbclient=sqlite --no-run > create.log 2>&1"
```

The install takes about two minutes, longer than the 30-second default of `sbx exec`, so it runs detached. Poll `tail create.log` until it prints:

```text
added 1398 packages, and audited 1399 packages in 2m
…
 Strapi   Your application was created!
```

### Fix the empty database path first

With these flags, the generated `.env` had an empty `DATABASE_FILENAME=`. Strapi's `config/database.js` falls back to `.tmp/data.db` only when the variable is missing, not when it is empty. So the SQLite path became the project folder itself, and the server crash-looped:

```text
warn: [database] knex: Acquire connection error: SqliteError: unable to open database file
error: unable to open database file
info: Shutting down Strapi
```

We printed the resolved path to confirm it:

```bash
sbx exec $id "cd cms && node -e \"process.env.DATABASE_FILENAME='';const c=require('./config/database.js')({env:require('@strapi/utils').env});console.log(c.connection.connection.filename)\""
```

```text
/home/user/cms
```

Set the path explicitly. While you are in `.env`, bind Strapi to `127.0.0.1`. The sandbox has a public IPv6 address, and the hosted URL can reach a localhost-only app through `--localhost`:

```bash
sbx exec $id "cd cms && sed -i 's|^DATABASE_FILENAME=\$|DATABASE_FILENAME=.tmp/data.db|' .env"
sbx exec $id "cd cms && sed -i 's/^HOST=0.0.0.0/HOST=127.0.0.1/' .env"
```

## 2. Add a content type, then build

Content types are files under `src/api`. We added an `article` type with a title, body and cover image. These are the same files the admin's Content-Type Builder writes:

```json
{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" },
  "options": { "draftAndPublish": true },
  "attributes": {
    "title": { "type": "string", "required": true },
    "body": { "type": "text" },
    "cover": { "type": "media", "multiple": false, "allowedTypes": ["images"] }
  }
}
```

That file goes in `src/api/article/content-types/article/schema.json`. Then add a one-line controller, router and service, each in its own folder:

```js
// src/api/article/controllers/article.js (routes/article.js and services/article.js use createCoreRouter and createCoreService)
module.exports = require('@strapi/strapi').factories.createCoreController('api::article.article');
```

Build the admin panel for production:

```bash
sbx exec $id --timeout 300 "cd cms && NODE_ENV=production npm run build"
```

```text
✔ Building build context (89ms)
✔ Building admin panel (35062ms)
```

## 3. Run it with systemd and create the admin

```ini
# strapi.service
[Unit]
Description=Strapi
After=network.target

[Service]
Type=simple
User=user
WorkingDirectory=/home/user/cms
Environment=NODE_ENV=production
ExecStart=/usr/bin/node node_modules/.bin/strapi start
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sbx scp strapi.service $id:/home/user/
sbx exec $id "sudo cp strapi.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now strapi"
```

Create the first admin with the [CLI](https://docs.strapi.io/cms/cli), so nobody can claim the admin panel by opening it first. The password comes from a file and is never printed. Strapi wants at least 8 characters with an uppercase letter, a lowercase letter and a digit:

```bash
sbx exec $id "umask 077; echo \"Sb\$(openssl rand -hex 12)\" > ~/admin-pass; cd cms && NODE_ENV=production \
  npx strapi admin:create-user --firstname=Ada --lastname=Admin --email=admin@example.com --password=\"\$(cat ~/admin-pass)\""
```

```text
Successfully created new admin
```

Check the service with `journalctl -u strapi`. That is where the database error in step 1 showed up.

## 4. Host it and set the two proxy settings

```bash
sbx host $id 1337 --localhost --title strapi
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_cvah5xd2lv","port":1337,"url":"https://smooth-vital-sierra-1337.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":true}
```

The admin panel is at `/admin` and the API at `/api` on the same host. TLS ends at the Sandboxer edge, which sends Strapi plain HTTP with `X-Forwarded-Proto: https`. Strapi's [server config](https://docs.strapi.io/cms/configurations/server) has two settings for this: `url`, the public address, and `proxy.koa`, which tells Strapi to trust the forwarded headers. We put both behind environment variables in `config/server.js`:

```js
module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', ''),
  proxy: { koa: env.bool('IS_PROXIED', false) },
  app: { keys: env.array('APP_KEYS') },
  webhooks: { populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false) },
});
```

```bash
sbx exec $id "cd cms && echo PUBLIC_URL=https://smooth-vital-sierra-1337.box.sandboxer.com >> .env \
  && echo IS_PROXIED=true >> .env && sudo systemctl restart strapi"
```

We logged in to the admin API from our test machine before and after. Without `proxy.koa`, Strapi thought the request was plain HTTP and left `Secure` off its refresh cookie, even though the browser was on HTTPS:

```text
set-cookie: strapi_admin_refresh=<value>; path=/admin; expires=Tue, 10 Nov 2026 11:45:19 GMT; samesite=lax; httponly
```

With `IS_PROXIED=true`:

```text
set-cookie: strapi_admin_refresh=<value>; path=/admin; expires=Tue, 10 Nov 2026 11:47:01 GMT; samesite=lax; secure; httponly
```

Login worked both ways, so the only symptom of the missing setting is the weaker cookie.

`url` turned out to be less fragile than in apps that store their own address. In Strapi 5.59 the admin bundle calls the API with a relative path when both are on the same origin: the built JavaScript contained `STRAPI_ADMIN_BACKEND_URL:"/"`. We built once with `PUBLIC_URL=http://localhost:1337`, and the admin still loaded and logged in through the hosted URL. You don't need to rebuild if the hosted URL changes. Set `url` anyway: Strapi's docs say it builds absolute links from it, for example in password reset emails, and the startup log then prints the right admin address.

## 5. Two gates: the proxy and Strapi

From our test machine, `$B` is the base URL and `$T` the URL's token:

```bash
curl -s -o /dev/null -w "no token: %{http_code}\n" $B/admin
curl -s -c jar -o /dev/null -w "token: %{http_code} -> %{redirect_url}\n" "$B/admin?_token=$T"
curl -s -b jar -o /dev/null -w "admin with cookie: %{http_code}\n" $B/admin
curl -s -b jar -w "\n%{http_code}\n" $B/api/articles
```

```text
no token: 401
token: 302 -> https://smooth-vital-sierra-1337.box.sandboxer.com/admin
admin with cookie: 200
{"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}}
403
```

The `401` comes from the Sandboxer proxy, and Strapi never sees the request. The `403` is Strapi's: new content types aren't public until you give the Public role permission, or the client sends an [API token](https://docs.strapi.io/cms/features/api-tokens). In a browser, open the full `?_token=` link once and the cookie does the rest.

## 6. Get an API token and write content from your laptop

You can create an API token in the admin panel under Settings, API Tokens. We did the same with two calls through the hosted URL. `login.json` holds the email and password:

```bash
curl -s -b jar -H 'Content-Type: application/json' -d @login.json $B/admin/login > login.out
AJ=$(python3 -c "import json; print(json.load(open('login.out'))['data']['token'])")
curl -s -b jar -H "Authorization: Bearer $AJ" -H 'Content-Type: application/json' \
  -d '{"name":"laptop","type":"full-access","lifespan":null}' $B/admin/api-tokens > token.out
K=$(python3 -c "import json; print(json.load(open('token.out'))['data']['accessKey'])")
```

Every request needs both gates: the `__sbx_auth` cookie for the proxy and `Authorization: Bearer` for Strapi. The proxy passes `Authorization` through untouched. Then use the [REST API](https://docs.strapi.io/cms/api/rest). A plain create publishes. `?status=draft` keeps a draft ([status parameter](https://docs.strapi.io/cms/api/rest/status)):

```bash
curl -s -b jar -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
  -d '{"data":{"title":"Hello from a sandbox","body":"Written over the hosted URL."}}' $B/api/articles
curl -s -b jar -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
  -d '{"data":{"title":"Draft only"}}' "$B/api/articles?status=draft"
curl -sg -b jar -H "Authorization: Bearer $K" "$B/api/articles?fields[0]=title"
curl -s -H "Authorization: Bearer $K" -o /dev/null -w "token but no cookie: %{http_code}\n" $B/api/articles
```

```text
{"data":{"id":2,"documentId":"si6c6sg4vheslttv5nmz30qc","title":"Hello from a sandbox",…,"publishedAt":"2026-10-27T11:47:17.730Z"},"meta":{}}
{"data":{"id":3,"documentId":"sb1reucadu3wx1prjb0n94sn","title":"Draft only",…,"publishedAt":null},"meta":{}}
{"data":[{"id":2,"documentId":"si6c6sg4vheslttv5nmz30qc","title":"Hello from a sandbox"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":1}}}
token but no cookie: 401
```

Use `curl -g` for Strapi's bracket query strings. Without it, curl reads `fields[0]` as a URL range and sends nothing.

### Media files sit behind the same gate

Uploads go to `/api/upload` ([upload API](https://docs.strapi.io/cms/api/rest/upload)) and come back with a relative URL:

```bash
curl -s -b jar -H "Authorization: Bearer $K" -F files=@cover.png $B/api/upload
curl -s -b jar -H "Authorization: Bearer $K" -X PUT -H 'Content-Type: application/json' \
  -d '{"data":{"cover":1}}' $B/api/articles/si6c6sg4vheslttv5nmz30qc
curl -s -o /dev/null -w "no cookie: %{http_code}\n" $B/uploads/cover_ff4c8ce39e.png
curl -s -b jar -o /dev/null -w "with cookie: %{http_code} %{content_type}\n" $B/uploads/cover_ff4c8ce39e.png
```

```text
[{"id":1,"name":"cover.png",…,"url":"/uploads/cover_ff4c8ce39e.png",…}]
no cookie: 401
with cookie: 200 image/png
```

A frontend has to prefix the base URL, and a browser that never opened the token link gets `401` for the image. Media files don't need Strapi's API token, only the proxy cookie.

Over 20 list calls with the cover populated, the median was 195 ms from our test machine and 7.7 ms inside the sandbox. The difference is the network path to `eu-central`, not Strapi. Code that runs in the sandbox should call `http://127.0.0.1:1337`.

## 7. Schema changes need `develop`, not `start`

In production mode, the Content-Type Builder refuses changes. The admin API answered:

```text
{"data":null,"error":{"status":403,"name":"PolicyError","message":"Content-Type Builder modifications are disabled in production mode. Schema changes can only be made when running with autoReload enabled (strapi develop).","details":{}}}
```

To edit schemas in the browser, stop the service and run `npm run develop` instead, through the same hosted URL. We did, and it worked:

- `/admin` loaded from Vite in development mode (`/admin/@vite/client`).
- Vite's hot-reload WebSocket upgraded through the proxy: `101 Switching Protocols` on `wss://…-1337.box.sandboxer.com/admin/` with the `vite-hmr` subprotocol.
- Creating a `note` type through the builder API returned `201`, wrote `src/api/note`, and Strapi reloaded and answered again 4.1 s later.
- Memory in use was 0.9 GB, against 0.6 GB under `strapi start`.

Then stop `develop`, run `npm run build` again and start the service. The `note` type was there, and the API token from step 6 could write to `/api/notes` straight away. The schema lives in files, so commit `src/api` if you keep the project in git.

While nothing listened on 1337, between stopping `develop` and starting the service, the URL returned a `502` page from Sandboxer titled "Nothing is answering". Here it meant our app was between a stop and a start; it went away once Strapi was listening again.

## 8. Stop, resume and a boot from disk

```bash
sbx stop $id --wait
sbx resume $id
sbx info $id      # "memoryRestored": true
sbx exec $id "pgrep -f 'strapi start'; systemctl show -p NRestarts strapi"
```

```text
4906
…
NRestarts=0
```

PID 4906 was the same process as before the stop, and systemd counted no restarts. Both articles were there, and the admin refresh cookie from before the stop still got a new access token (`POST /admin/access-token` returned `200`).

A resume boots from disk instead when you resize or no machine with the same CPU type is free. We forced that with `sbx stop $id --wait` and `sbx resume $id --type default`, then polled `/_health` through the hosted URL until it returned `204`. Our timing script printed:

```text
stop --wait 2.2 s, resume 3.4 s, resume->health 204 via URL 1.0 s
```

`sbx info` showed `"memoryRestored": false` and `"resumeReason": "resized"`. Uptime was 8.7 s when we checked, so this was a fresh boot. The `enabled` unit started Strapi again, and the articles, the cover image under `public/uploads` and the admin session all survived. They live in `.tmp/data.db` and on disk. Moving to a smaller size isn't supported yet, so a resize to `default` stays at `default` ($0.036/hour instead of $0.018).

## 9. Clean up

Copy out what you want to keep first: `.tmp/data.db`, `public/uploads` and `src`, with `sbx scp`. Then:

```bash
sbx delete $id --yes
```

After the delete, `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.

## FAQ

### Why does Strapi say "unable to open database file" on first start?

Check `DATABASE_FILENAME` in `.env`. Our non-interactive `create-strapi` run left it empty, and an empty value makes Strapi open the project folder as the database. Set it to `.tmp/data.db`.

### Do I need to rebuild the admin panel when the hosted URL changes?

No, in our test with Strapi 5.59. When the admin and API share an origin, the build uses a relative API path, so the bundle doesn't contain the host. Restart after changing `PUBLIC_URL` so links and the startup log use it.

### Is `small` big enough for Strapi?

For one developer and a SQLite database, yes. The admin build is the peak: memory in use reached 2.6 GB of 4 GB. The server used about 240 MB and `develop` about 0.9 GB total. If a build runs out of memory, the process is killed with exit code 137 and the sandbox stays up. Create the sandbox as `default` then.

### Can I make the API public for a frontend?

`sbx host $id 1337 --localhost --public` drops the proxy's cookie check, so the admin login page and anything you opened to Strapi's Public role are reachable by anyone with the URL. Keep a strong admin password. Switching from public back to private mints a new token, so every client needs the new cookie.

### Should I use Postgres instead of SQLite?

For a test or preview instance, SQLite keeps everything in one file and needed no extra services here. Strapi also supports Postgres and MySQL ([database config](https://docs.strapi.io/cms/configurations/database)). We tested only SQLite.

## Related

- [Run PocketBase in a sandbox](/guides/run-pocketbase-in-a-sandbox)
- [Run WordPress in a sandbox](/guides/run-wordpress-in-a-sandbox)
- [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume)
- [Hosting](/docs/hosting)
