# Run Docker Compose in a Sandbox With a URL per Service

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

Source: https://sandboxer.com/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service. Updated 2026-10-27.

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

```yaml
# 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:
```

```python
# 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

```bash
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"
```

```text
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

```bash
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
```

```text
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:

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

```bash
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)"
```

```text
/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](/docs/snapshots)). To see what Compose does then, we stopped and resumed with `--type large`, which always cold-boots:

```bash
sbx stop $id --wait
sbx resume $id --type large
sbx exec $id "cat /proc/uptime; docker ps -a --format 'table {{.Names}}\t{{.Status}}'"
```

```text
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](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) and the [Compose `restart` attribute](https://docs.docker.com/reference/compose-file/services/#restart).

## 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](/guides/docker-and-kvm-inside-an-agent-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`.

## Related

- [Hosting](/docs/hosting)
- [Snapshots](/docs/snapshots)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
