Skip to content
Guides · Recipes

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

Updated October 27, 2026 .md

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

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. These flags skip every prompt and the Strapi Cloud login:

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:

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:

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:

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)\""
/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:

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:

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

// 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:

sbx exec $id --timeout 300 "cd cms && NODE_ENV=production npm run build"
✔ Building build context (89ms)
✔ Building admin panel (35062ms)

3. Run it with systemd and create the admin#

# 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
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, 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:

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)\""
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#

sbx host $id 1337 --localhost --title strapi
{"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 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:

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) },
});
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:

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

With IS_PROXIED=true:

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:

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

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. A plain create publishes. ?status=draft keeps a draft (status parameter):

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
{"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) and come back with a relative URL:

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
[{"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:

{"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#

sbx stop $id --wait
sbx resume $id
sbx info $id      # "memoryRestored": true
sbx exec $id "pgrep -f 'strapi start'; systemctl show -p NRestarts strapi"
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:

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:

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). We tested only SQLite.

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