Skip to content
Guides · How-tos

Run Docker Compose in a Sandbox With a URL per Service

Updated October 27, 2026 .md

Short answer: Copy your compose.yaml into a Sandboxer sandbox, run docker compose up -d (Docker and Compose are preinstalled), then run sbx host for each published port. Each service gets its own HTTPS URL. A memory-restoring stop and resume keeps containers running; add restart unless-stopped so they also come back after a cold boot.

At a glance#

Step Command What we saw (single runs, October 27, 2026)
Check versions sbx exec $id "docker compose version" Docker 29.9.0, Docker Compose v5.6.0, docker.service enabled
Start the stack sbx exec $id "cd app && docker compose up -d" 3 images pulled and 3 containers started in about 4 seconds
One URL per service sbx host $id 3000 --public, sbx host $id 8000 https://<subdomain>-3000… public, https://<subdomain>-8000…?_token=… private
Internal services No ports:, no host Redis reachable from the API container only
Stop and resume sbx stop $id --wait, sbx resume $id Same container PIDs and start times, counter kept counting
Cold boot (resize) sbx resume $id --type large Containers with a restart policy came back; one without it stayed Exited (255)

The stack#

A web front end (nginx), an API (Python, standard library only) and Redis. The API counts hits in Redis. Only web and api publish ports; Redis stays on the Compose network.

# app/compose.yaml
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "3000:80"
    volumes:
      - ./web:/usr/share/nginx/html:ro
    restart: unless-stopped

  api:
    image: python:3.12-alpine
    command: python /app/api.py
    environment:
      REDIS_HOST: redis
    ports:
      - "8000:8000"
    volumes:
      - ./api.py:/app/api.py:ro
    depends_on:
      - redis
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    restart: unless-stopped

volumes:
  redis-data:
# app/api.py
import json, os, socket
from http.server import BaseHTTPRequestHandler, HTTPServer

def incr(key):
    # One Redis INCR over a raw socket, so the API needs no packages.
    with socket.create_connection((os.environ["REDIS_HOST"], 6379)) as s:
        s.sendall(f"*2\r\n$4\r\nINCR\r\n${len(key)}\r\n{key}\r\n".encode())
        return int(s.recv(64).decode().strip().lstrip(":"))

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps({"service": "api", "hits": incr("hits")}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

app/web/index.html holds one line of HTML.

Run it#

sbx new --name compose-demo
id=sb_glb31hca2m    # the id that sbx new printed

sbx scp -r ./app $id:/home/user/
sbx exec $id "cd app && docker compose up -d"
sbx exec $id "cd app && docker compose ps"
NAME          IMAGE                COMMAND                  SERVICE   CREATED                  STATUS                  PORTS
app-api-1     python:3.12-alpine   "python /app/api.py"     api       Less than a second ago   Up Less than a second   0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp
app-redis-1   redis:7-alpine       "docker-entrypoint.s…"   redis     1 second ago             Up Less than a second   6379/tcp
app-web-1     nginx:1.27-alpine    "/docker-entrypoint.…"   web       1 second ago             Up Less than a second   0.0.0.0:3000->80/tcp, [::]:3000->80/tcp

Publish ports on 0.0.0.0 (the Compose default for "3000:80"). The hosting proxy connects to the sandbox's own address, so a port published only on 127.0.0.1 needs sbx host … --localhost.

Give each service a URL#

sbx host $id 3000 --public --title web
# https://mellow-modern-pine-3000.box.sandboxer.com

sbx host $id 8000 --title api
# https://mellow-modern-pine-8000.box.sandboxer.com?_token=<token>
# Private URL: keep the _token secret. `sbx host sb_glb31hca2m 8000 --public` makes it ungated.

sbx host list $id
PORT  ACCESS   URL                                                               TITLE
3000  public   https://mellow-modern-pine-3000.box.sandboxer.com                 web
8000  private  https://mellow-modern-pine-8000.box.sandboxer.com?_token=<token>  api

The URL pattern is https://<subdomain>-<port>.box.sandboxer.com, so each published port is its own hostname with TLS. What curl returned:

curl -i https://mellow-modern-pine-3000.box.sandboxer.com/
# HTTP/2 200
# <h1>web: served by nginx inside a Sandboxer sandbox</h1>

curl -o /dev/null -w '%{http_code}\n' https://mellow-modern-pine-8000.box.sandboxer.com/
# 401   (no token)

curl -L -c jar.txt -b jar.txt "https://mellow-modern-pine-8000.box.sandboxer.com/?_token=<token>"
# {"service": "api", "hits": 1}

The first request with the token sets a cookie, so later requests from the same browser don't need it. Redis has no hosted port, so nothing outside the sandbox can reach it. Keep databases that way and let your services talk over the Compose network.

Stop and resume: containers keep running#

We started one extra container without a restart policy (docker run -d --name no-policy nginx:1.27-alpine), noted every container's PID and start time, then stopped and resumed.

sbx stop $id --wait     # resume is refused with sandbox_stopping until the snapshot is done
sbx resume $id
sbx exec $id "docker inspect -f '{{.Name}} pid={{.State.Pid}} started={{.State.StartedAt}}' \$(docker ps -q)"
/no-policy pid=1045 started=2026-10-27T02:26:48.205625423Z
/app-api-1 pid=921 started=2026-10-27T02:26:30.404893038Z
/app-web-1 pid=735 started=2026-10-27T02:26:30.118245588Z
/app-redis-1 pid=852 started=2026-10-27T02:26:30.267902655Z

Same PIDs and start times as before the stop, including the container with no restart policy: this resume restored memory. That happens only on a machine with the same CPU type; if none is free within 2 minutes, the resume cold-boots as in the next section. The API answered {"hits": 3}, continuing from 2: Redis kept its in-memory data. While the sandbox was stopped, the URLs returned an error page instead of your app (404 in one run, 503 "Sandbox is not running" in another). After resume they answered again with no sbx host call; routes and tokens are kept.

Cold boot: only containers with a restart policy come back#

Resume boots from disk instead of restoring memory when you change size, when the memory image can't be used, or when no machine with the same CPU type is free (see Snapshots). To see what Compose does then, we stopped and resumed with --type large, which always cold-boots:

sbx stop $id --wait
sbx resume $id --type large
sbx exec $id "cat /proc/uptime; docker ps -a --format 'table {{.Names}}\t{{.Status}}'"
1.95 11.17
NAMES         STATUS
no-policy     Exited (255) 1 second ago
app-api-1     Up Less than a second
app-web-1     Up Less than a second
app-redis-1   Up Less than a second

An uptime of 2 seconds confirms a fresh boot. Docker is an enabled systemd service, so it started, and it restarted the three Compose containers because of restart: unless-stopped. The container without a policy stayed down. Both URLs worked without re-hosting.

One thing did not survive: the hit counter. That first run used Redis without --appendonly yes, and the API answered {"hits": 1}. Redis held the count in memory and had not written it to disk, and a cold boot doesn't restore memory. With --appendonly yes (the file above), we killed Redis with docker kill -s KILL app-redis-1 and started it with docker compose start redis (Docker treats a kill as a manual stop and doesn't restart it): the log said DB loaded from append only file and the count continued from 4 to 5. Turn on your database's durability settings if a cold boot must not lose writes. See Redis persistence and the Compose restart attribute.

How to tell which resume you got#

Check the processes rather than guessing:

  • cat /proc/uptime small after resume: cold boot. Large and still counting from before the stop: memory was restored.
  • docker inspect -f '{{.State.StartedAt}}' unchanged: the container never restarted.
  • sbx info reports memoryRestored: true after a memory resume and false after a boot from disk, with resumeReason (cpu_mismatch, resized, no_memory_snapshot or restore_failed). In our runs on 2026-10-27, before that fix shipped later the same night, the field read null both ways; the uptime and container checks above work either way.

To refuse a cold boot, use sbx resume $id --require-memory. It waits up to 30 minutes for a machine that can restore memory, and fails instead of booting from disk. Ours resumed with memory: /proc/uptime read 16.6 seconds, counting on from before the stop.

FAQ#

Which size should I use for a Compose stack?#

default (4 vCPU, 8 GB, 50 GB disk) ran this three-container stack with room to spare. Use large for heavy builds or many containers. Growing on resume works. Moving to a smaller size isn't supported yet (our resumes from large back to default, and from default to small, were refused with type_too_small), so pick the size you plan to stay on. To get a smaller machine, create a new sandbox at that size, copy your project and compose.yaml across (for example with sbx scp -r or a git clone), run docker compose up -d there and host its ports again.

Do I need privileged containers or the host's Docker socket?#

No. Each sandbox is its own VM with its own Docker daemon. user is in the docker group, so docker compose runs without sudo. See Docker and KVM in a sandbox.

Can I keep some service URLs private?#

Yes. sbx host is private by default: the URL carries a _token and returns 401 without it. Add --public per port to open it to anyone. Use --public only for services you're happy for anyone to reach.

Do I have to re-host ports after a stop?#

No. Hosted routes and tokens are kept across stop and resume, and the subdomain never changes. A fork gets a new subdomain and no routes, so host its ports again.

Does the sandbox stop on its own?#

Yes, after one hour by default, and after a period of inactivity. Both are memory-preserving stops. Change them with sbx extend $id --hours 12 or sbx idle $id 0.

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