# How to Run Postgres with pgvector in a Cloud Sandbox

> Run the official pgvector/pgvector:pg17 Docker image in a Sandboxer microVM with --shm-size=1g and --restart unless-stopped, then create the vector extension and an HNSW index. On 10,000 random 384-dimension vectors, a top-10 query took 6 to 7 ms without the index and 2.6 to 5.9 ms with it.

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

**Short answer:** Run the official pgvector/pgvector:pg17 Docker image in a Sandboxer microVM with --shm-size=1g and --restart unless-stopped, then create the vector extension and an HNSW index. On 10,000 random 384-dimension vectors, a top-10 query took 6 to 7 ms without the index and 2.6 to 5.9 ms with it.

## What we ran

Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB): PostgreSQL 17.11, pgvector 0.8.7, Docker image `pgvector/pgvector:pg17`. The vectors are random numbers made by Postgres itself. There is no embedding model in this guide, so the timings measure the database, not a model. All timings are single runs or three back-to-back runs; your numbers will differ.

| Step | Result |
| - | - |
| Pull the image and start Postgres | 8.6 s |
| Insert 10,000 vectors of 384 dimensions | 1.4 s |
| Build the HNSW index | 1.3 s (20 MB index, 16 MB table) |
| Top-10 cosine query, no index | 6.0 to 6.8 ms (warm), sequential scan |
| Same query, HNSW index, default `ef_search` (40) | 2.6 to 5.9 ms, 7 of the 10 exact neighbors found |
| Same query, HNSW index, `ef_search = 200` | 6.6 to 7.4 ms, 10 of 10 found |
| Same query from a laptop through an SSH tunnel | 155 ms median round trip |
| `sbx stop --wait` then `sbx resume` | Memory restored: Postgres kept running |
| Resume on a bigger size (boots from disk) | Docker restarted Postgres; rows and index intact |

## 1. Start pgvector in a sandbox

pgvector publishes a Docker image with the extension already built ([pgvector install docs](https://github.com/pgvector/pgvector#docker)). Docker is preinstalled in every sandbox, so this is one command:

```bash
sbx new --name pgvector --type default --no-env
id=sb_yra5cldf6n    # the id that sbx new printed

sbx exec $id --timeout 300 "docker run -d --name pg --restart unless-stopped --shm-size=1g \
  -e POSTGRES_PASSWORD=pw -p 127.0.0.1:5432:5432 -v pgdata:/var/lib/postgresql/data \
  pgvector/pgvector:pg17"
sbx exec $id "until docker exec pg pg_isready -q; do sleep 0.5; done; docker exec pg pg_isready"
```

```text
Status: Downloaded newer image for pgvector/pgvector:pg17
/var/run/postgresql:5432 - accepting connections
```

Three flags matter:

- `--shm-size=1g`. Docker gives a container 64 MB of `/dev/shm` by default. Our first run left it out, and the parallel HNSW build failed (output below). With 1 GB, the same build worked.
- `--restart unless-stopped`. When the sandbox boots from disk instead of restoring memory, Docker starts Postgres again.
- `-p 127.0.0.1:5432:5432`. Postgres listens inside the VM only. Nothing outside can reach it. You connect through SSH (step 4).

This is the error without `--shm-size`:

```text
ERROR:  could not resize shared memory segment "/PostgreSQL.1852724404" to 265326016 bytes: No space left on device
```

## 2. Load 10,000 vectors

Save this as `load.sql`. It makes 10,000 rows of 384 random `real` values each. 384 is the size of many small text-embedding models; use your model's size if you have one.

```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE items (id bigint PRIMARY KEY, embedding vector(384) NOT NULL);
\timing on
INSERT INTO items
SELECT i, array_agg(random()::real ORDER BY d)::vector(384)
FROM generate_series(1, 10000) AS i, generate_series(1, 384) AS d
GROUP BY i;
\timing off
SELECT count(*), vector_dims(embedding) AS dims FROM items GROUP BY 2;
SELECT extversion FROM pg_extension WHERE extname = 'vector';
```

```bash
sbx scp ./load.sql $id:/home/user/
sbx exec $id "docker exec -i pg psql -U postgres -v ON_ERROR_STOP=1 < load.sql"
```

```text
CREATE EXTENSION
CREATE TABLE
INSERT 0 10000
Time: 1373.018 ms (00:01.373)
 count | dims
-------+------
 10000 |  384
 extversion
------------
 0.8.7
```

## 3. Measure the query with and without an index

Save this as `knn.sql`. It takes row 42's vector as the query and asks for the 10 nearest rows by cosine distance (`<=>`):

```sql
SELECT embedding AS q FROM items WHERE id = 42 \gset
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10;
```

Without an index, Postgres reads every row and sorts:

```bash
sbx scp ./knn.sql $id:/home/user/
sbx exec $id "docker exec -i pg psql -U postgres < knn.sql"
```

```text
 Limit (actual time=4.618..5.980 rows=10 loops=1)
   ->  Gather Merge (actual time=4.618..5.979 rows=10 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Sort (actual time=3.261..3.262 rows=8 loops=2)
               Sort Key: ((embedding <=> '[…]
               Sort Method: top-N heapsort  Memory: 25kB
               Worker 0:  Sort Method: top-N heapsort  Memory: 25kB
               ->  Parallel Seq Scan on items (actual time=0.005..2.965 rows=5000 loops=2)
 Planning Time: 0.038 ms
 Execution Time: 5.999 ms
```

Now build an HNSW index with the operator class that matches the query's distance (`vector_cosine_ops` for `<=>`). Save as `index.sql`:

```sql
SET maintenance_work_mem = '256MB';
\timing on
CREATE INDEX items_embedding_hnsw ON items USING hnsw (embedding vector_cosine_ops);
\timing off
SELECT pg_size_pretty(pg_relation_size('items_embedding_hnsw')) AS index_size,
       pg_size_pretty(pg_table_size('items')) AS table_size;
```

```text
CREATE INDEX
Time: 1293.373 ms (00:01.293)
 index_size | table_size
------------+------------
 20 MB      | 16 MB
```

Run `knn.sql` again. The plan is now an index scan:

```text
 Limit (actual time=2.486..2.546 rows=10 loops=1)
   ->  Index Scan using items_embedding_hnsw on items (actual time=2.484..2.544 rows=10 loops=1)
         Order By: (embedding <=> '[…]
 Planning Time: 0.083 ms
 Execution Time: 2.572 ms
```

Three runs took 2.6, 3.0 and 5.9 ms, against 6.0 to 6.8 ms for the warm sequential scan.

### Check what the index gives up

HNSW is approximate. To see how many true neighbors it returns, compare it with an exact scan. Save as `recall.sql`:

```sql
SELECT embedding AS q FROM items WHERE id = 7 \gset
CREATE TEMP TABLE approx AS SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10;
SET enable_indexscan = off;
CREATE TEMP TABLE exact AS SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10;
SELECT count(*) AS overlap_of_10 FROM approx JOIN exact USING (id);
```

```text
 overlap_of_10
---------------
             7
```

With the default search list (`hnsw.ef_search = 40`, see the [pgvector query options](https://github.com/pgvector/pgvector#query-options)), the index found 7 of the 10 exact neighbors. With `SET hnsw.ef_search = 200;` at the top of both files, it found all 10, and the query took 6.6 to 7.4 ms, the same as no index.

Two things explain this. Uniform random vectors are a hard case for approximate search, because every point is about the same distance from every other point. Real embeddings cluster, so recall at the default setting is usually better; measure it on your own data with the file above. And 10,000 rows is small: a sequential scan of 16 MB is already fast. Measure at your real row count before you decide the index is worth its memory and build time.

## 4. Connect from your laptop through an SSH tunnel

Postgres listens on `127.0.0.1` inside the VM, so forward a local port to it over SSH. Add the CLI's SSH stanza once, connect once so the CLI authorizes its key, then open the tunnel:

```bash
sbx ssh-config --install          # adds a Host *.sbx block (ProxyCommand sbx proxy %h)
sbx ssh $id true                  # authorizes the CLI's key on this sandbox
ssh -f -N -o ExitOnForwardFailure=yes -L 15432:127.0.0.1:5432 $id.sbx
```

Any Postgres client on your laptop can now use `127.0.0.1:15432`. We used Python with `psycopg`:

```python
import statistics, time
import psycopg

with psycopg.connect("host=127.0.0.1 port=15432 user=postgres password=pw dbname=postgres") as conn:
    print(conn.execute("SELECT extversion FROM pg_extension WHERE extname = 'vector'").fetchone()[0])
    q = conn.execute("SELECT embedding::text FROM items WHERE id = 42").fetchone()[0]
    times = []
    for _ in range(20):
        t0 = time.perf_counter()
        rows = conn.execute(
            "SELECT id, round((embedding <=> %s::vector)::numeric, 4) AS distance "
            "FROM items ORDER BY embedding <=> %s::vector LIMIT 5",
            (q, q),
        ).fetchall()
        times.append((time.perf_counter() - t0) * 1000)
    print(rows)
    print(f"median {statistics.median(times):.1f} ms, min {min(times):.1f} ms over {len(times)} queries")
```

```text
0.8.7
[(9123, Decimal('0.2115')), (6790, Decimal('0.2185')), (8243, Decimal('0.2205')), (900, Decimal('0.2211')), (4904, Decimal('0.2216'))]
median 155.1 ms, min 154.8 ms over 20 queries
```

The query itself takes about 3 ms inside the VM. The rest of the 155 ms is the network path from our client to the sandbox and back, which depends on where you are. For a quick check this is fine. For load tests or an app that queries in a loop, run the client in the sandbox too.

Close the tunnel when you are done by killing that `ssh` process. [`sbx forward`](/docs/ssh#forward-ports) is the CLI's built-in alternative to `ssh -L`; we tested the `ssh -L` route only.

The query vector's own row (42) is not in the results above. With the HNSW index and the default `ef_search`, an approximate search can miss even an exact match. That is the same recall trade-off as in step 3.

## 5. What survives a stop and resume

We ran `CHECKPOINT`, then stopped and resumed the sandbox:

```bash
sbx exec $id "docker exec pg psql -U postgres -c CHECKPOINT"
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'; docker exec pg psql -U postgres -tAc 'select count(*) from items'"
```

```text
pg Up 2 minutes
10000
```

The stop took 2.2 seconds and the resume 1.4 seconds. The resume reported `memoryRestored: true`: the container was never restarted (`Up 2 minutes`), and the tunnel script worked again with the same results.

A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced that case with `sbx resume $id --type large` (`memoryRestored: false`, `resumeReason: "resized"`). Docker's restart policy brought Postgres back, and the data survived:

```text
pg Up 2 seconds
10000
items_pkey
items_embedding_hnsw
```

So the table, the data and the HNSW index live on the sandbox disk and survive both paths. Only a process you started by hand without a restart policy is lost on a boot from disk. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy the data. See [snapshots](/docs/snapshots#resume) for the rules.

## 6. Clean up

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

A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped.

## FAQ

### Why does CREATE INDEX fail with "No space left on device"?

Docker's default `/dev/shm` is 64 MB, and a parallel index build in Postgres uses shared memory. Start the container with `--shm-size=1g`. With a 64 MB `/dev/shm`, our build failed at once; with 1 GB, it finished in 1.3 seconds.

### Do I need the HNSW index for 10,000 rows?

Probably not. In our run, the sequential scan took 6 to 7 ms and always returned the exact top 10. The index was up to twice as fast at default settings but missed 3 of the 10 neighbors on random data. Measure at your real row count and on your real embeddings.

### Which operator class do I use?

Match it to the distance in your `ORDER BY`: `vector_cosine_ops` for `<=>` (cosine), `vector_l2_ops` for `<->` (Euclidean), `vector_ip_ops` for `<#>` (inner product). If they don't match, Postgres can't use the index and falls back to a sequential scan. The full list is in the [pgvector README](https://github.com/pgvector/pgvector#hnsw).

### Can I expose Postgres on a public URL instead of a tunnel?

A [hosted URL](/docs/hosting) is an HTTPS address, built for web apps and APIs. A Postgres client expects a plain Postgres connection on a TCP port, so we used SSH and didn't test a hosted URL for this. The tunnel also keeps the database private: Postgres listens only on `127.0.0.1` in the VM, and opening the tunnel takes your API key.

### Will my data survive if the sandbox auto-stops?

Yes. Auto-stop is a normal stop: the disk and memory are saved, and `sbx resume` brings it back. Run `CHECKPOINT` before a planned stop so Postgres has flushed its data files, and keep `--restart unless-stopped` for the times a resume boots from disk.

## Related

- [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres)
- [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor)
- [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [SSH and file transfer](/docs/ssh)
