How to Run Postgres with pgvector in a Cloud Sandbox
Updated October 27, 2026 .md
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). Docker is preinstalled in every sandbox, so this is one command:
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"Status: Downloaded newer image for pgvector/pgvector:pg17
/var/run/postgresql:5432 - accepting connectionsThree flags matter:
--shm-size=1g. Docker gives a container 64 MB of/dev/shmby 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:
ERROR: could not resize shared memory segment "/PostgreSQL.1852724404" to 265326016 bytes: No space left on device2. 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.
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';sbx scp ./load.sql $id:/home/user/
sbx exec $id "docker exec -i pg psql -U postgres -v ON_ERROR_STOP=1 < load.sql"CREATE EXTENSION
CREATE TABLE
INSERT 0 10000
Time: 1373.018 ms (00:01.373)
count | dims
-------+------
10000 | 384
extversion
------------
0.8.73. 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 (<=>):
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:
sbx scp ./knn.sql $id:/home/user/
sbx exec $id "docker exec -i pg psql -U postgres < knn.sql" 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 msNow build an HNSW index with the operator class that matches the query's distance (vector_cosine_ops for <=>). Save as index.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;CREATE INDEX
Time: 1293.373 ms (00:01.293)
index_size | table_size
------------+------------
20 MB | 16 MBRun knn.sql again. The plan is now an index scan:
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 msThree 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:
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); overlap_of_10
---------------
7With the default search list (hnsw.ef_search = 40, see the 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:
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.sbxAny Postgres client on your laptop can now use 127.0.0.1:15432. We used Python with psycopg:
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")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 queriesThe 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 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:
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'"pg Up 2 minutes
10000The 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:
pg Up 2 seconds
10000
items_pkey
items_embedding_hnswSo 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 for the rules.
6. Clean up#
sbx delete $id --yesA 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.
Can I expose Postgres on a public URL instead of a tunnel?#
A hosted URL 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#
More guides
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.
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.
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.
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.