# How to Run NocoDB in a Sandbox With a Private HTTPS URL

> Run NocoDB and Postgres with Docker Compose in a small Sandboxer sandbox, create the admin from environment variables and host port 8080 with sbx host --localhost. Scripts send the __sbx_auth cookie plus an xc-token API token. Turn on invite-only signup, because anyone with the private link can otherwise register.

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

**Short answer:** Run NocoDB and Postgres with Docker Compose in a small Sandboxer sandbox, create the admin from environment variables and host port 8080 with sbx host --localhost. Scripts send the __sbx_auth cookie plus an xc-token API token. Turn on invite-only signup, because anyone with the private link can otherwise register.

[NocoDB](https://nocodb.com/docs/) puts a spreadsheet-style UI and a REST API on top of a SQL database. With Postgres as its database, every NocoDB table is a real Postgres table, so the same rows are visible to the grid, the API and `psql`.

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB, region `eu-central`) with NocoDB 2026.10.0 from `nocodb/nocodb:latest` and `postgres:17`. The browser checks used headless Chromium (Playwright) on our test machine. Tokens and passwords are replaced with `<token>` and `<password>`. Single runs unless the table says otherwise.

| Step | Result |
| - | - |
| `sbx new --type small` | 3.8 s to ready |
| `docker compose up -d` with both pulls, until `/api/v1/health` answered | 40 s; NocoDB itself was up 13 s after its container started |
| Memory after start | NocoDB 799 MB, Postgres 59 MB; 2.6 GB of 3.9 GB still available |
| List 3 records through the hosted URL, from our test machine | median 0.21 s over 20 calls (0.20 to 0.22 s) |
| Same call inside the sandbox on `localhost:8080` | median 25 ms over 20 calls |
| `sudo systemctl restart docker` until healthy | 13.4 s, browser session and API token still valid |
| `sbx stop --wait`, then `sbx resume` | 2.9 s and 2.1 s, `memoryRestored: true`, browser still signed in |
| Resume with `--type default` (boots from disk) | 4.0 s, NocoDB healthy 13 s after boot, data and API token intact |

`small` is enough. NocoDB's image is 906 MB, and the Node process uses about 800 MB of RAM.

## 1. The Compose file

NocoDB's [README](https://github.com/nocodb/nocodb#docker-with-pg) runs the image with `NC_DB` pointing at Postgres and a fixed `NC_AUTH_JWT_SECRET`. This file does the same with Postgres in the stack, and uses the [environment variables](https://nocodb.com/docs/self-hosting/environment-variables) that matter in a sandbox:

```yaml
# noco/compose.yaml
services:
  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: noco
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      POSTGRES_DB: noco
    volumes:
      - pg:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "noco", "-d", "noco"]
      interval: 3s
      retries: 30

  nocodb:
    image: nocodb/nocodb:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      NC_DB: pg://db:5432?u=noco&p=${PG_PASSWORD}&d=noco
      NC_AUTH_JWT_SECRET: ${JWT_SECRET}
      NC_ADMIN_EMAIL: admin@example.com
      NC_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      NC_SITE_URL: ${SITE_URL}
      NC_DISABLE_TELE: "true"
    volumes:
      - data:/usr/app/data

volumes:
  pg:
  data:
```

Why these settings:

- `NC_ADMIN_EMAIL` and `NC_ADMIN_PASSWORD` create the super admin at startup. Without them, the first person to open the UI creates the admin account.
- `NC_SITE_URL` is the address NocoDB uses for links in emails, such as invites and password resets. The UI itself worked from the hosted URL without extra settings, because it calls the API on the origin it was loaded from.
- `127.0.0.1:8080:8080`. The sandbox has a public IPv6 address, and Docker can [bypass ufw rules](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw). Publishing on loopback keeps NocoDB reachable only through the hosted URL.
- `restart: unless-stopped` brings both containers back when Docker or the VM restarts.

## 2. Get the URL first, then start

The URL goes into `NC_SITE_URL`, so host the port before you start. A private URL can exist before anything listens on the port:

```bash
sbx new --name nocodb --type small --no-env
id=sb_ig50v88ri0    # the id that sbx new printed; its subdomain was maple-alert-cinder

sbx host $id 8080 --localhost --title NocoDB
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_ig50v88ri0","port":8080,"url":"https://maple-alert-cinder-8080.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":true}
```

Copy the folder in, write random secrets to `.env` without printing them, and start the stack. The pulls take longer than `sbx exec`'s 30-second default, so raise the timeout:

```bash
sbx scp -r ./noco $id:/home/user/
sbx exec $id --timeout 600 "cd noco && umask 077 && {
    echo PG_PASSWORD=\$(openssl rand -hex 16)
    echo JWT_SECRET=\$(openssl rand -hex 32)
    echo ADMIN_PASSWORD=\$(openssl rand -hex 12)
    echo SITE_URL=https://maple-alert-cinder-8080.box.sandboxer.com
  } > .env && docker compose up -d --quiet-pull \
  && until curl -fs localhost:8080/api/v1/health >/dev/null; do sleep 0.5; done \
  && curl -s localhost:8080/api/v1/health && docker compose ps"
```

```text
 Container noco-db-1 Healthy
 Container noco-nocodb-1 Started
{"message":"OK","timestamp":1793101540180,"uptime":13.188656253}
SERVICE   IMAGE                  STATUS                    PORTS
db        postgres:17            Up 16 seconds (healthy)   5432/tcp
nocodb    nocodb/nocodb:latest   Up 13 seconds             127.0.0.1:8080->8080/tcp
```

Read the admin password when you need it with `sbx exec $id "grep ADMIN_PASSWORD noco/.env"`.

## 3. Open the UI through the private URL

| Request | Result |
| - | - |
| `/` or `/api/v1/health`, no token | `401`, the Sandboxer "Access token required" page |
| `/?_token=<token>` | `302` to `/`, sets the `__sbx_auth` cookie |
| `/` with the cookie | `200`, the NocoDB sign-in page |

In a browser, open the full `?_token=` link once, then sign in as `admin@example.com`. We did this in headless Chromium: the sign-in worked, the workspace loaded, and a deep link such as `/w9sd0xte/p00ntx9hkxipp65/m1ajgz0zz889w09/…/contacts-contacts` opened the grid directly on a fresh load. NocoDB keeps its own session in `httpOnly` cookies (`nc_token`, `refresh_token`) next to the proxy's `__sbx_auth`, and they don't interfere.

## 4. Close signup before you share the link

With `NC_ADMIN_EMAIL` set, NocoDB still allows public signup. Anyone who has your private link can register. We tried it through the hosted URL, with the `__sbx_auth` cookie but no NocoDB login:

```bash
curl -s -b jar -H 'Content-Type: application/json' \
  -d '{"email":"stranger@example.com","password":"<password>"}' $U/api/v1/auth/user/signup
```

```text
{"token":"<token>"}
```

The new account appeared in the user list with the `org-level-viewer` role. Turn on invite-only signup as the super admin. NocoDB's docs put the switch in the super admin settings; we used the API, which needs a signed-in session token (`xc-auth`), because NocoDB refuses API tokens for app settings (`ERR_API_TOKEN_NOT_ALLOWED`):

```bash
curl -s -b jar -H "xc-auth: $JWT" -H 'Content-Type: application/json' \
  -d '{"invite_only_signup":true}' $U/api/v1/app-settings
```

```text
{"msg":"The app settings have been saved"}
```

The same signup request then returned `400`: `{"msg":"Not allowed to signup, contact super admin."}`. NocoDB also documents an `NC_INVITE_ONLY_SIGNUP` environment variable for this; we used the setting instead. Check the user list for accounts created before you locked it.

## 5. Call the API from your laptop

There are two gates. The proxy wants `__sbx_auth` (its value is the URL's token). NocoDB wants an `xc-token` [API token](https://nocodb.com/docs/product-docs/developer-resources/rest-apis). The proxy passes `xc-token` through untouched.

Sign in once to create a long-lived API token. `$U` is the base URL, `$T` the URL token, and `login.json` holds the admin email and password:

```bash
U=https://maple-alert-cinder-8080.box.sandboxer.com
curl -s -o /dev/null -c jar "$U/?_token=$T"       # trade the URL token for the cookie once

JWT=$(curl -s -b jar -H 'Content-Type: application/json' -d @login.json \
  $U/api/v1/auth/user/signin | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
X=$(curl -s -b jar -H "xc-auth: $JWT" -H 'Content-Type: application/json' \
  -d '{"description":"laptop"}' $U/api/v1/tokens | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
```

Use `$X` from here on, not the sign-in token. A password sign-in signs out that user's other sessions: our browser landed on `/signin` right after a script signed in as the same user, while the API token kept working. We saw this twice.

Create a base, a table and records with the API token:

```bash
curl -s -b jar -H "xc-token: $X" -H 'Content-Type: application/json' -d '{"title":"crm"}' $U/api/v2/meta/bases
# {"id":"p00ntx9hkxipp65","title":"crm",…}

curl -s -b jar -H "xc-token: $X" -H 'Content-Type: application/json' -d @table.json \
  $U/api/v2/meta/bases/p00ntx9hkxipp65/tables
# {"id":"m1ajgz0zz889w09","table_name":"contacts",…}

curl -s -b jar -H "xc-token: $X" -H 'Content-Type: application/json' \
  -d '[{"Name":"Ada Lovelace","Email":"ada@example.com","Stage":"customer"},
       {"Name":"Alan Turing","Email":"alan@example.com","Stage":"lead"}]' \
  $U/api/v2/tables/m1ajgz0zz889w09/records
```

```json
{"table_name":"contacts","title":"Contacts","columns":[
 {"column_name":"name","title":"Name","uidt":"SingleLineText"},
 {"column_name":"email","title":"Email","uidt":"Email"},
 {"column_name":"stage","title":"Stage","uidt":"SingleSelect","dtxp":"'lead','customer'"}]}
```

```text
[{"Id":1},{"Id":2}]
```

That JSON is `table.json`. Filtering uses NocoDB's `where` syntax; the v3 data API answered on the same host:

```bash
curl -s -b jar -H "xc-token: $X" "$U/api/v2/tables/m1ajgz0zz889w09/records?fields=Id,Name,Stage&where=(Stage,eq,lead)"
curl -s -b jar -H "xc-token: $X" "$U/api/v3/data/p00ntx9hkxipp65/m1ajgz0zz889w09/records?fields=Name,Stage"
```

```text
{"list":[{"Id":2,"Name":"Alan Turing","Stage":"lead"}],"pageInfo":{"totalRows":1,…}}
{"records":[{"id":1,"id_fields":{"Id":1},"fields":{"Name":"Ada Lovelace","Stage":"customer"}},{"id":2,…}]}
```

How to tell which gate said no:

| Request | Answer |
| - | - |
| `xc-token`, no cookie | `401` HTML, "Access token required" (the Sandboxer proxy) |
| `Cookie: __sbx_auth=<token>` header plus `xc-token` | `200` |
| Cookie, bad `xc-token` | `401` JSON, `ERR_AUTHENTICATION_REQUIRED` (NocoDB) |

From our test machine the list call took 0.21 s median; inside the sandbox it took 25 ms. Code that runs in the sandbox should call `http://localhost:8080` and skip the cookie.

## 6. The tables are plain Postgres

Each NocoDB base is a Postgres schema named after the base id. Rows written with SQL show up in the API and the grid:

```bash
sbx exec $id "cd noco && docker compose exec -T db psql -U noco -d noco \
  -c 'select id, name, email, stage from p00ntx9hkxipp65.contacts order by id' \
  -c \"insert into p00ntx9hkxipp65.contacts (name, email, stage) values ('Grace Hopper', 'grace@example.com', 'lead')\""
```

```text
 id |     name     |      email       |  stage
----+--------------+------------------+----------
  1 | Ada Lovelace | ada@example.com  | customer
  2 | Alan Turing  | alan@example.com | lead
(2 rows)

INSERT 0 1
```

The next `where=(Stage,eq,lead)` call returned both Alan Turing and Grace Hopper, and the grid showed 3 records. This is useful for loading data with `COPY` or letting a script in the sandbox write with SQL while people edit in the UI. NocoDB's metadata lives in the `public` schema of the same database, so don't drop tables there.

## 7. Shared views still need the sandbox token

NocoDB can share a view as a read-only link. We created one with `POST /api/v2/meta/views/<view id>/share`, which returned a `uuid`, and opened `/nc/view/<uuid>` in a fresh browser with no NocoDB login:

| Link | Result |
| - | - |
| `/nc/view/<uuid>` | `401` from the Sandboxer proxy |
| `/nc/view/<uuid>?_token=<token>` | `200`, the Contacts grid, read only |

So a share link from a private sandbox must carry the sandbox token, and anyone with that token can reach the sign-in page and every other share link too. To share views with outsiders, host the port with `--public` and rely on NocoDB's own auth. Switching between private and public mints a new token for private links.

## 8. What survives restarts, stop and resume

**Docker restart.** `sudo systemctl restart docker` took both containers down. `restart: unless-stopped` brought them back, and NocoDB answered `/api/v1/health` 13.4 s later. The browser session and the API token both still worked.

**Stop and resume.**

```bash
sbx stop $id --wait     # 2.9 s
sbx resume $id          # 2.1 s
sbx info $id            # "memoryRestored": true
```

The containers kept running through it (`Up 5 minutes`), the API returned all records, and the headless browser opened the grid deep link without signing in again.

**Boot from disk.** A resize or a resume on a different CPU type boots from disk. We stopped again and ran `sbx resume $id --type default`: `memoryRestored: false`, `resumeReason: resized`. Docker started with the VM, NocoDB was healthy 13 s after boot, and the records and API token were intact.

## 9. Clean up

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

After the delete, `sbx info` returned `not_found` and the URL returned 404. Export anything you need first, for example with `pg_dump` inside the `db` container and `sbx scp`. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped.

## FAQ

### Can I use SQLite instead of Postgres?

Yes. NocoDB's README runs the image with only a volume on `/usr/app/data`, and it then keeps everything in a SQLite file there. We tested Postgres, because it lets you read and write the same tables with SQL and `pg_dump`.

### Why did my script sign me out of the browser?

In our tests, a password sign-in through `/api/v1/auth/user/signin` ended the same user's other sessions. Sign in once to create an API token, then send it as `xc-token`. API tokens survived sign-ins, Docker restarts and resumes.

### Do I need `NC_AUTH_JWT_SECRET`?

NocoDB's README sets it, and so did we. In one test we emptied it and recreated the container, and the existing browser session still worked, so this version seems to keep the secret in its own database. Set it anyway, as the docs recommend.

### Is `small` big enough?

For a team workspace or a demo, yes. NocoDB used about 800 MB and Postgres 59 MB, leaving 2.6 GB of the 3.9 GB that `free` reported available. Moving to a smaller size isn't supported yet, so start small and resize up if needed.

### Can an app in the same sandbox use the API?

Yes. Call `http://localhost:8080` with `xc-token`; no cookie is needed, and the call took 25 ms median against 0.21 s through the hosted URL from our test machine.

## Related

- [Run PocketBase in a sandbox](/guides/run-pocketbase-in-a-sandbox)
- [Run Supabase locally in a cloud sandbox](/guides/run-supabase-locally-in-a-cloud-sandbox)
- [Run Metabase in a sandbox](/guides/run-metabase-in-a-sandbox)
- [Hosting](/docs/hosting)
