Team setup: several services, AWS plus an office network, Sentry

A worked example for a team whose product is several services, whose dev environment is split between AWS and an office network, and whose error tracking is Sentry. It covers connecting both networks, running the services the agent changed under the names the rest of dev uses, keeping Sentry, using a changed service from another box, and what to check when something does not connect. Everything here is what ParallelSandbox does today; where it cannot do something, this page says so.

One account for the team

Everything in ParallelSandbox belongs to one account: boxes, environments and their connections, links, published versions, the image registry and the credits. A team works in one account:

  • Each teammate, or each agent, connects with the one line from Quick start and signs in to the account in the browser once (OAuth). Nothing is created or shared in advance. Only tools without OAuth need a psbx_ API key: the account owner makes one per teammate or agent over REST with the app's signed-in session (POST /v1/keys with a name; shown once), lists them with GET /v1/keys and revokes one with DELETE /v1/keys/{id}, which stops it within a minute. An API key cannot create, list or revoke keys.
  • Every key acts on the whole account: an agent with any of its keys can see and operate every box of the account, start boxes with its environments, link to any of its boxes and run any of its versions. Links and versions work across all keys of the account and never across accounts.
  • All keys draw on the account's credits and plan.
  • Only the account's own login opens the app (the box list, takeovers, billing). Teammates work through their own connections, and the person trying a change needs only the box's URL.

With several teammates and agents on one account:

  • All keys have the same power: any of them can stop, wire or take over any box of the account, including a box another agent started. There are no per-key permissions; a separate key per teammate or agent is what lets you revoke one without the others.
  • There is one pool of credits. Every agent's boxes draw on it, and sandbox_status.credits shows the same balance to all of them.
  • The box limit is the account's too: Free 3, Pro 5, Max 20 boxes at once, frozen boxes included. When it is reached, sandbox_start fails for everyone with plan <plan> allows <n> boxes at once and <m> are running or frozen; frozen boxes still hold a slot, so list them with sandbox_list (GET /v1/boxes) and sandbox_stop one you no longer need. (followed by which box the idle rules stop first); sandbox_start with waitForCapacitySec waits in the call for a slot.
  • Teammates find each other's boxes with sandbox_list: it lists every box of the account, whoever started it, with its name, goal, status, URLs, services (a link's fromBox, a version) and the boxes linked to it, and it never wakes a box. Use it to find a box's id for a link, to see what teammates run, to find forgotten boxes, and to pick up a box another conversation left (Tool reference).
  • So each agent should sandbox_stop the boxes it is done with instead of leaving them frozen, unless it hands one to the person with sandbox_review: a frozen box costs nothing but keeps its slot until it is stopped, 24 hours after its last use unless idleTimeoutMin is set. A stopped box frees its slot at once. A box left with neither shows in the person's app as AI stopped, waiting for them to decide what happens to it.
  • Boxes record the MCP client that started them (clientName), not the API key, so only a box's name says whose it is, and its goal what it is for. Agents should name boxes <person>: <task> and put the rest in goal, stop only boxes they started (they keep the ids sandbox_start returned) or whose name says they are theirs, never stop boxes in bulk by age, and ask before stopping a teammate's box.

Who sees what:

  • The account has no members or roles. Up to one GitHub, one Google and one Apple sign-in can be linked to it (under Account), and each opens the same account with full access, so link only your own.
  • The app, for whoever is signed in to the account, shows the box cards, the Use it button, takeovers and the agents' sandbox_say messages (and lets them leave notes for the agent).
  • Teammates who are not signed in get webUrl and each services[].url from their own agent (sandbox_start, sandbox_status or sandbox_list). Those URLs work for anyone who has them. The app's AI stopped boxes (a conversation closed or a box unused for an hour, with no card waiting) reach them the same way: sandbox_list shows each box's agent.state, and left or idle marks one nobody is working on; they tell their agent to pick it up by name or id (Picking up a box another conversation left).
  • takeoverUrl opens the box's page in the app and needs the account's sign-in. The link sandbox_takeover creates (…/box/<id>?t=<token>, valid 30 minutes, and 30 minutes from when it is first opened) opens that one takeover without signing in; it is pushed to the account's mobile app, and the tool's progress messages carry it.

The example system

Piece Runs on Address its callers use
web, Next.js front end, port 3000 ECS service web, cluster acme-dev https://dev.acme.example (public load balancer)
api, port 8080 ECS service api api.acme.internal:8080 (Cloud Map DNS name)
billing, port 8080 ECS service billing billing.acme.internal:8080
admin, back-office web app, port 8080 ECS service admin behind an internal load balancer internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80
Postgres Aurora (RDS) acme-dev.cluster-c1a2b3d4e5f6.ap-northeast-1.rds.amazonaws.com:5432
Redis ElastiCache acme-dev.x1y2z3.ng.0001.apne1.cache.amazonaws.com:6379
ledger, port 9000 a server in the office ledger.office.lan:9000
ledger's database the office 10.20.0.15:5432
Sentry sentry.io, or self-hosted in the office DSN https://<key>@o123.ingest.sentry.io/456, or https://<key>@sentry.office.lan/7

The agent changed api, later billing as well, and then admin. Everything else stays on dev.

1. One environment, two connections

An environment has any number of connections. Make one per network, here aws and office, in the same environment dev:

  • Each connection has its own token, its own connector (run several copies of it for redundancy) and its own address list of up to 100 host:port.
  • When a program in a box opens an address, it goes through the connection that lists that exact host:port. List every address on the connection whose network can reach it; if two connections list the same address, the one whose name sorts first is used.
  • A box takes at most 200 addresses from all connections together. Addresses are exact host names or IPv4 addresses with a port, TCP only.
  • The connector resolves names in its own network, so Route 53 private zones, Cloud Map names and office DNS work as they do for your services.

The agent creates dev (POST /v1/environments), then two connections, aws and office (POST /v1/environments/dev/connections, twice). Each answer carries its token once, in the runCommand.

Connection aws: a Fargate task in the VPC

Run the connector as an ECS service in the subnets and security group of your services. Keep its token in SSM:

aws ssm put-parameter --name /parallelsandbox/connector-token --type SecureString --value 'psbx_conn_...'
aws logs create-log-group --log-group-name /ecs/parallelsandbox-connector

aws ecs register-task-definition --family parallelsandbox-connector \
  --requires-compatibilities FARGATE --network-mode awsvpc --cpu 256 --memory 512 \
  --execution-role-arn arn:aws:iam::<account>:role/ecsTaskExecutionRole \
  --container-definitions '[{"name":"connector","image":"public.ecr.aws/b2n6a1j1/connector:latest","essential":true,
    "secrets":[{"name":"PSBX_CONNECTOR_TOKEN","valueFrom":"arn:aws:ssm:ap-northeast-1:<account>:parameter/parallelsandbox/connector-token"}],
    "logConfiguration":{"logDriver":"awslogs","options":{"awslogs-group":"/ecs/parallelsandbox-connector","awslogs-region":"ap-northeast-1","awslogs-stream-prefix":"connector"}}}]'

aws ecs create-service --cluster acme-dev --service-name parallelsandbox-connector \
  --task-definition parallelsandbox-connector --desired-count 1 --launch-type FARGATE \
  --network-configuration 'awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-services],assignPublicIp=DISABLED}'
  • The execution role must be allowed ssm:GetParameters on that parameter, and kms:Decrypt on the KMS key when the SecureString is encrypted with a customer-managed key (the default aws/ssm key needs nothing more).
  • The subnets need a way out to the internet (a NAT gateway for private subnets): the task pulls the image from public.ecr.aws and connects to api.parallelsandbox.com:443. Nothing connects in.
  • Every target the connector reaches must accept the connector's security group on the target's port: RDS (5432), ElastiCache (6379), the internal load balancer on its listener port (80 or 443), the ECS tasks themselves on their container ports (Cloud Map names such as api.acme.internal resolve to task IPs, so those connections go straight to a task), and anything else you list. Running the connector with your services' security group covers the targets that already accept that group; allow it on the rest.
  • If your services find each other through ECS Service Connect rather than DNS, the names resolve only inside tasks of that Service Connect namespace; run the connector service with Service Connect enabled in the same namespace.

Addresses for aws, one per line, exactly as the services' settings write them:

acme-dev.cluster-c1a2b3d4e5f6.ap-northeast-1.rds.amazonaws.com:5432
acme-dev.x1y2z3.ng.0001.apne1.cache.amazonaws.com:6379
api.acme.internal:8080
billing.acme.internal:8080
internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80

List the internal names of the services you might change as well, like api.acme.internal:8080 here: a box that leaves api alone reaches dev's copy through the connector, and a box that declares api.acme.internal at sandbox_start takes the name over, so it disappears from that box's reachable.

The internal load balancer on port 80 is listed like any other address. Each environment host gets an address of its own inside the box, and boxd does not listen on the address's port, so host:80 does not collide with boxd's own port 80, and a process of yours can listen on the same port number as any of these addresses.

After you import service settings (below), GET /v1/environments/dev/suggested-endpoints returns the addresses found in them: every host:port in a value, every URL (without a port, its scheme's default: postgres 5432, mysql 3306, redis 6379, mongodb 27017, amqp 5672, http 80, https 443) and every *_HOST variable with a matching *_PORT, kept when the host is a private IPv4 address (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) or ends in .internal, .local, .lan, .corp, .svc, .cluster.local, .rds.amazonaws.com, .cache.amazonaws.com, .es.amazonaws.com, .mq.amazonaws.com, .docdb.amazonaws.com or .redshift.amazonaws.com. Anything else, such as an internal load balancer's *.elb.amazonaws.com name or a private zone with your own domain, you add yourself. The agent sets the whole list with PUT /v1/environments/dev/connections/<id>/endpoints. To look addresses up:

aws rds describe-db-clusters --query 'DBClusters[].[Endpoint,ReaderEndpoint,Port]' --output text
aws rds describe-db-instances --query 'DBInstances[].[Endpoint.Address,Endpoint.Port]' --output text
aws elasticache describe-replication-groups --query 'ReplicationGroups[].[ReplicationGroupId,NodeGroups[0].PrimaryEndpoint.Address,NodeGroups[0].PrimaryEndpoint.Port]' --output text
aws elbv2 describe-load-balancers --query 'LoadBalancers[?Scheme==`internal`].[LoadBalancerName,DNSName,LoadBalancerArn]' --output text
aws elbv2 describe-listeners --load-balancer-arn <arn> --query 'Listeners[].[Port,Protocol]' --output text

Clients that learn more addresses at run time connect to whatever the server hands them: a Redis Cluster's node IPs, Kafka's advertised brokers, a MongoDB replica set's members. List each of those host:port as well, or they will not connect from a box.

Connection office: a host in the office

On a Linux machine in the office that reaches the ledger, its database and Sentry:

docker run -d --name parallelsandbox-connector --restart=always \
  -e PSBX_CONNECTOR_TOKEN=psbx_conn_... \
  -e PSBX_ALLOW=ledger.office.lan:9000,10.20.0.15:5432,sentry.office.lan:443 \
  public.ecr.aws/b2n6a1j1/connector:latest

Addresses for office:

ledger.office.lan:9000
10.20.0.15:5432
sentry.office.lan:443

PSBX_ALLOW is optional; it enforces the same list again on your side. If the host resolves *.office.lan but the container does not, start the connector with --dns <office DNS server> or --network host.

Service settings

  • Import from AWS (POST /v1/environments/dev/services/import with {"region":"ap-northeast-1","cluster":"acme-dev","service":"api"}, once per service): api, billing, web and admin. This needs the read-only role from Import service settings from AWS, created once by whoever holds the AWS account. Plain environment values are copied, SSM and Secrets Manager references are resolved; environmentFiles in S3 are not read and come back in sourceRef.skipped. Each becomes /work/.sbx/env/<service>.env and .sh in a box.
  • If a service keeps part of its settings in an S3 environmentFiles file, upload that file's contents under a second name (PUT /v1/environments/dev/services/api-files with {"dotenv": "…"}), for example api-files, and start the service with both files, that one first so the task definition's own values win as they do on ECS: docker run --env-file /work/.sbx/env/api-files.env --env-file /work/.sbx/env/api.env ... (or . api-files.sh && . api.sh). Do not upload it under the imported name: an upload replaces that service's settings.
  • Upload ledger's settings the same way (PUT /v1/environments/dev/services/ledger), if a box will ever run it.
  • Leave externalBaseUrl empty unless you need it (see Public dev endpoints).

AWS role for the moved services

On ECS, api and billing get their AWS permissions (S3, SQS and so on) from their task role, not from their settings, and a box does not have that role. Give the environment one instead, as AWS permissions inside a box describes: the agent gets the CloudFormation link from GET /v1/environments/dev (awsBoxRoleQuickCreateUrl), whoever holds the AWS account creates the role with it and grants it what the moved services' task roles allow (plus ECR pulls if a box will pull dev's images), and the agent sets it with PUT /v1/environments/dev and {"awsRoleArn", "awsRegion"}. Boxes started after that get temporary credentials the way ECS tasks do; containers get them through --env-file /work/.sbx/env/<service>.env.

The agent checks the result:

sandbox_environments {}

dev should list all eight addresses in reachable, both aws and office with online: true in connections[], and api, billing, web, admin and ledger under services with their envFile.

connectorOnline: true means at least one connection is up, not both. Read connections[] to see each one: online, sessions, lastSeenAt, connectorVersion and reachable, the addresses that go through it. When some are offline, sandbox_start's next names them, for example These connections have no connector online right now, so their addresses will refuse connections until their connector runs again: office (ledger.office.lan:9000, 10.20.0.15:5432, sentry.office.lan:443). The final check is still to use one of the network's addresses from a running box with a real client (curl, pg_isready, psql and redis-cli are installed in the box), then read that address in sandbox_status → health.privateEndpoints[]. A bare TCP connect proves nothing: boxd accepts it before the connector is asked. When the connector of office is down, its addresses show failed and lastError HTTP 503: the connector "office" of this environment is offline; start it in your network (docker run … parallelsandbox connector) and retry, and the client sees its connection reset.

2. Run the changed services under their dev names

How names work in a box:

  • A name in services gets an address of its own inside the box from the moment the box starts, and the box's DNS answers the name with it, for processes and containers alike. If it is also an environment address, it is taken out of reachable for that box, so it points at your copy instead of the connector. Everything you do not declare still goes to dev through the connectors.
  • Only programs inside the box see this. Services on dev, their queues and their cron jobs keep calling dev's api: nothing can open a connection from your networks into a box. To test a chain that passes through api, run the callers you need in the box too (Run dev's unchanged callers in the box).
  • port in services is the port callers dial, and name:port reaches your process on targetPort, which defaults to port. Callers keep the address they use on dev, and the process can listen on another port: two changed services that both listen on 8080 keep api.acme.internal:8080 and billing.acme.internal:8080, each with its own targetPort (see A second changed service on the same port).
  • The process must listen on 0.0.0.0, on its targetPort. One bound to 127.0.0.1 is reachable through its URL but not by its name.
  • Ports 80 and 9095 in the box belong to boxd (80 is the scene entry, 9095 its API), and no process or container can listen on them. A service that callers reach on port 80, such as one behind an internal load balancer, keeps its name and port 80 in the box: declare it with a targetPort, the port your process listens on (see A changed service behind the internal load balancer). Without targetPort, sandbox_start fails with 400. Every other port is free, 443 and other privileged ports included: commands run as root and containers publish ports as usual.
  • To take over an environment address, declare the name at sandbox_start, or later with sandbox_wire on a box whose sandbox_status → health.features includes take-over: the name keeps its address, connections open through the connector are closed, and it reaches the box's copy from then on. On older boxes a name added later that is one of the environment's addresses keeps going to the connector.
  • What is neither declared nor listed stays out of reach, each in its own way (observed on a box):
    • A declared name or an environment host dialed on another port stays in the box: whatever listens on that port on 0.0.0.0 in the box answers, and with nothing there the connection is refused at once. It never reaches your network or another box.
    • A host name that is neither declared nor an environment address is looked up in public DNS, not in yours. A name only your private DNS knows (Cloud Map, Route 53 private zones, office DNS) does not resolve (Could not resolve host). A name public DNS resolves to a private IP, such as the RDS endpoint or an internal-…elb.amazonaws.com name, resolves, and the connection times out like any unlisted private IP; list it to reach it. Public names go straight to the internet.
    • A private IP address that is not listed gets no answer: the connection times out, and nothing is written to private-endpoints.log. A listed IP address dialed on a port that is not listed behaves the same, for example 10.20.0.15:22.
    • Port 80 is different. An environment address listed on port 80, such as the internal load balancer, and a link declared on port 80 work like any other name: every name has its own address and never meets the box's own port 80. Only a service in the box needs targetPort for port 80. A name dialed on 80 that is not declared or listed on 80 (a service or link declared on another port, or an environment host listed only on other ports) reaches boxd's scene entry, which answers 502 nothing listens on <name>:80 in this box: that name is declared on another port. Dial the port it is declared on, or declare it on port 80 with targetPort, the port your process listens on, from programs and containers alike. localhost:80 is still the scene entry and answers with the first declared service. A box started before 2026-09-24 20:30 UTC answers such a name on 80 with the first declared service instead. Port 9095, boxd's API, answers 404 page not found.

Start the box

sandbox_start {
  "name": "ana: api refund rework",
  "goal": "Make the member center refund button post the refund to the ledger; done when a refund goes through end to end in the browser on dev's data",
  "environment": "dev",
  "size": 2,
  "services": [
    { "name": "web", "port": 3000, "web": true },
    { "name": "api.acme.internal", "port": 8080 }
  ]
}
  • web: true gives web a URL the person can open. Its URL is the result's webUrl, the first web service's URL (here also sceneUrl, because web happens to be declared first). The person opens it on their own phone or laptop to try the change, and the app's Use it button opens the same URL wherever web sits in the list. api is not marked web, so it has no URL and nothing outside the box can reach it.
  • api.acme.internal is the exact name web and the other services use for api, so web in the box reaches api in the box with no setting changed.
  • size: 2 (4 vCPU, 16 GB): the box builds and runs several service images, and later more of them. Size 1 fits a web front end or one or two Node, Python or Go services.
  • name starts with the person the box is for, so teammates' agents can tell whose it is in sandbox_list. goal says what the box is for and when it is done: the person reads it on the box's card, and a teammate's agent, or a new conversation after this one is closed, reads it with the steps done so far in sandbox_status.
  • Programs in the box and containers on any Docker network, compose included, reach api.acme.internal and the environment's addresses the same way.
  • The result's environment.reachable no longer lists api.acme.internal, and still lists RDS, Redis, billing.acme.internal, the internal load balancer, ledger.office.lan, 10.20.0.15 and sentry.office.lan.

Put the checkout in and start both services with their dev settings:

sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -e SENTRY_ENVIRONMENT=psbx -e SENTRY_RELEASE=a1b2c3d -e SBX_BOX_ID -p 8080:8080 api", "cwd": "platform", "timeoutSec": 900, "note": "build and start the changed api with its dev settings" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t web ./web && docker run -d --name web --env-file /work/.sbx/env/web.env -p 3000:3000 web", "cwd": "platform", "timeoutSec": 900, "note": "build and start the web front end with its dev settings" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://api.acme.internal:8080/health && curl -s -o /dev/null -w '%{http_code}\\n' http://localhost:3000/", "note": "check that api and web answer" }

-e after --env-file wins, so one value can differ from dev without editing the file.

What reaches what:

Caller Dials Reaches
web in the box api.acme.internal:8080 api in the box
api in the box RDS, Redis, billing.acme.internal:8080, the internal load balancer dev in AWS, through aws
api in the box ledger.office.lan:9000, 10.20.0.15:5432, sentry.office.lan:443 the office, through office
api in the box o123.ingest.sentry.io:443 and any public address the internet, directly
the person's browser webUrl web in the box, port 3000
web, billing on dev api.acme.internal:8080 api on dev, unchanged

Pages the person opens

A page loaded through webUrl, or any other URL of the box, runs in the person's browser, outside the box. Only requests to the box's URLs reach the box, and each URL reaches one service: sceneUrl the first declared service, a services[].url its own service. If the front end's browser code calls an absolute URL (NEXT_PUBLIC_API_URL=https://dev.acme.example/api), those requests go to dev, not to the api in the box. Browser code could call the api in the box cross-origin only if api were marked web, with its URL handed to the page (it cannot be built) and CORS allowing the page's origin. The simpler way is a same-origin proxy: have the browser call a path on the front end's own origin, and let web's server forward it inside the box to http://api.acme.internal:8080. Forward the path exactly as dev does: if dev's load balancer passes /api/... to api unchanged, as a path rule usually does, keep the prefix in the destination; strip it only if something on dev strips it.

  • api expects /api/orders (prefix kept): Next.js rewrites() { source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' }; Vite server: { proxy: { '/api': 'http://api.acme.internal:8080' } }.
  • api expects /orders (prefix stripped): Next.js rewrites() { source: '/api/:path*', destination: 'http://api.acme.internal:8080/:path*' }; Vite server: { proxy: { '/api': { target: 'http://api.acme.internal:8080', rewrite: (p) => p.replace(/^\/api/, '') } } }.

Build web in the box with that setting. If dev sends /api to the API at the load balancer (a path rule) rather than with a rewrite in web, the box has no such load balancer in front of web: add the rewrite to the box's build, or the page's /api/... calls reach web itself and fail. Browsers inside the box (Playwright, Chromium on the display) resolve every declared name and need none of this.

Next.js fixes two things when next build runs: the NEXT_PUBLIC_* values, which are written into the browser code, and the rewrites() of next.config.js, which go into the build output. So both must be in place before the box builds web. First the /api rewrite, in the box's copy of web/next.config.js (next to dev's other settings in that file, if it has one):

module.exports = {
  async rewrites() {
    return [{ source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' }];
  },
};

Then the build, with the settings: . /work/.sbx/env/web.sh exports every setting in the box's shell, and docker build takes each variable named with --build-arg from there:

cd /work/platform && . /work/.sbx/env/web.sh && export NEXT_PUBLIC_API_URL=/api
docker build $(sed -n 's/^\(NEXT_PUBLIC_[A-Za-z0-9_]*\)=.*/--build-arg \1/p' /work/.sbx/env/web.env) -t web ./web

The Dockerfile has to declare each one (ARG NEXT_PUBLIC_API_URL in the stage that runs next build); Docker ignores an undeclared build argument with a warning. The export line is where a value changes for the box, here to the same-origin path; a NEXT_PUBLIC_* name that is not in web.env needs its own --build-arg NAME. Changing either later means building again: restarting the container does not pick it up. Or build on the box itself instead of in Docker: cd web && . /work/.sbx/env/web.sh && npm ci && npx next build.

There is no login in front of the box's URLs: the key in the URL is the only protection. Anyone who has webUrl can use web as the person does, including everything it reaches through the box: here api in the box and, behind it, dev's database and services. Share it only with people who may use dev. Chat apps (Slack, Teams, LINE and the like) fetch a URL pasted into a chat to build a preview: that fetch is a request like any other, so the preview service gets the page, the request counts as use of the box, and a fetch that asks for HTML wakes a frozen box like a page load. The URLs stop working when the box stops. What the box sends to the person's browser, pages, assets and API responses alike, counts as its outbound traffic (Credits).

The person does not have to keep the box awake. Their requests keep it awake for up to 2 hours after the last tool call or wake; someone still using a page after that may see the box freeze mid-session. When it has frozen, say because they came back after lunch, they just reload: the page shows "Waking this box…", reloads itself into the app within about 10 seconds, and the wake counts as use, so another 2 hours start. Only a page load wakes it; the open page's own requests do not, so a front end that stops answering after a long pause needs a reload. If the account is out of credits, the page says the box is paused. A stopped box does not come back; a new box has new URLs.

Requests through any of the box's URLs reach the process with Host: 127.0.0.1:<targetPort>; the public host is in X-Forwarded-Host (<id>-<key>.box.parallelsandbox.com, or <id>-<key>-<targetPort>.box.parallelsandbox.com), the scheme in X-Forwarded-Proto (https) and the client address in X-Forwarded-For. Dev servers that accept only known hosts accept that, and relative redirects work as they are. An app that builds absolute URLs, redirects or cookie domains from Host has to trust the forwarded headers instead (in Express, app.set('trust proxy', true)). Programs in the box that call a service by name send their own Host, such as api.acme.internal:8080.

Sign-in (SSO, OAuth) on a box URL

If web signs people in through your identity provider, the provider must accept the box's URL as a redirect URI, and each box's URLs have a random host of their own, so they cannot be registered in advance. Two ways that work:

  • Run the box's copy with the login your team uses locally: a seeded test user, or a dev-only auth mode, set through the service's settings or an override after --env-file.
  • Register the box's exact URL while the box runs. If your provider's admin API can change a dev client's allowed redirect URIs, the agent adds the URL it got in webUrl or services[].url right after sandbox_start and removes it before sandbox_stop. Examples: Amazon Cognito aws cognito-idp update-user-pool-client --callback-urls … (it resets every setting you leave out to its default, so pass the client's current settings too), Auth0's Management API PATCH /api/v2/clients/{id} with callbacks, and Keycloak's admin API PUT /admin/realms/{realm}/clients/{id} with redirectUris.

Never register a wildcard such as https://*.box.parallelsandbox.com/*. Every account's boxes share that domain, so any ParallelSandbox user could have your users' authorization codes sent to a box of theirs. Sign-in does not replace the URL's key either: the key stays the only protection in front of the box.

A second changed service on the same port

billing changed too, and it also listens on 8080, which api already uses in the box. Keep its name and port, and give it another targetPort:

sandbox_start {
  "name": "ana: api and billing refund rework",
  "goal": "Rework refunds in api and billing together; done when a refund made in the browser is recorded by the changed billing",
  "environment": "dev",
  "services": [
    { "name": "web", "port": 3000, "web": true },
    { "name": "api.acme.internal", "port": 8080 },
    { "name": "billing.acme.internal", "port": 8080, "targetPort": 8081 }
  ]
}
sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t billing ./billing && docker run -d --name billing --env-file /work/.sbx/env/billing.env -p 8081:8080 billing", "cwd": "platform", "timeoutSec": 900, "note": "build and start the changed billing, published on 8081" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 api", "cwd": "platform", "timeoutSec": 900, "note": "build and start the changed api" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://billing.acme.internal:8080/health && curl -fsS http://api.acme.internal:8080/health", "note": "check both answer under their dev names" }

Each name has its own address in the box, so billing.acme.internal:8080 reaches the billing container published on 8081 while api.acme.internal:8080 still reaches api. No caller setting changes: api keeps calling http://billing.acme.internal:8080 as it does on dev. billing.acme.internal is an environment address, so it has to be declared at sandbox_start to be taken over.

Run dev's unchanged callers in the box

On dev, only api calls billing, and dev's api keeps calling dev's billing. If the agent changed billing alone, nothing calls the copy in the box until a caller runs there too. Run api in the box unchanged, under its dev name, next to the changed billing:

sandbox_start {
  "name": "ana: billing refund rules",
  "goal": "Change billing's refund rules; done when dev's unchanged api, running in the box, gets the new refund amounts from the changed billing",
  "environment": "dev",
  "services": [
    { "name": "api.acme.internal", "port": 8080 },
    { "name": "billing.acme.internal", "port": 8080, "targetPort": 8081 }
  ]
}

Start the changed billing as in the previous section. Three ways to get dev's api:

  • Build it from source at the commit dev runs, with its dev settings: check that commit out in the box's copy, then docker build -t api ./api && docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 api.

  • Pull the image dev runs from your ECR, when the environment names an AWS role that may pull from that repository (AWS permissions inside a box):

    aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <account>.dkr.ecr.ap-northeast-1.amazonaws.com
    docker run -d --name api --env-file /work/.sbx/env/api.env -p 8080:8080 <account>.dkr.ecr.ap-northeast-1.amazonaws.com/api:<tag dev runs>
    

    Boxes are x86_64 (amd64). If dev runs api on Graviton (runtimePlatform.cpuArchitecture: ARM64 in its task definition) and the image has no amd64 variant, docker run fails with exec format error: build it from source as in the first way, or have dev's pipeline push a multi-architecture image (docker buildx build --platform linux/amd64,linux/arm64). Emulation is not installed (Tools).

    The role needs ecr:GetAuthorizationToken, and ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer and ecr:BatchGetImage on the repository (the AWS managed policy AmazonEC2ContainerRegistryReadOnly has all four). Without them the login fails with AccessDeniedException … is not authorized to perform: ecr:GetAuthorizationToken. The image dev runs is the one in the service's current task definition: aws ecs describe-services --cluster acme-dev --services api --query 'services[0].taskDefinition', then aws ecs describe-task-definition --task-definition <arn> --query 'taskDefinition.containerDefinitions[].image', if the role may read ECS; otherwise ask the person.

  • If a build of api is already published as a version (sandbox_versions { "service": "api" }), declare it and let the box run it: { "name": "api.acme.internal", "port": 8080, "version": "<label>" } (Run a published version).

Then drive the chain from the box: curl http://api.acme.internal:8080/... reaches api in the box, which calls the changed billing in the box. If the caller already runs in another of your boxes, you do not need a second copy: point the name at this box from that box with a link, sandbox_wire { "id": "<box running api>", "service": "billing.acme.internal", "port": 8080, "fromBox": "<this box>" } (Links to other boxes).

Writes go to dev's database

A changed service in the box runs with its dev settings, so it talks to dev's real database: its migrations and every write it makes land there, for everyone on dev. When the change includes migrations, or the tests write or delete data, give the box a database of its own:

  • For tests, psbx-testdb up <name> starts a clean Postgres 16 in the box (--mysql: MySQL 8.0; data in memory, gone with the box) on a random port, waits until it accepts connections and writes /work/.sbx/testdb/<name>.env as export lines: DATABASE_URL, TEST_DATABASE_URL, TEST_POSTGRES_URI (Postgres), the PG* variables (so psql just works) and Laravel's DB_*; for MySQL, MYSQL_* in place of PG*. It prints one line per database and the . /work/.sbx/testdb/<name>.env to run, not the connection string; psbx-testdb url <name> prints that, password included. It listens on 127.0.0.1 in the box: programs running on the box reach it, a container only with --network host. The rest, from psbx-testdb --help:

    • Tests that read another variable find nothing, skip, and look like passes (all SKIP, or ok in 0.0x seconds): --as NAME (repeatable) writes the connection under that name too, for example psbx-testdb up --as BILLING_TEST_DSN.
    • Several names start several databases at once (psbx-testdb up a b c). A name already running is kept as it is, data included; psbx-testdb reset <name> drops and recreates its database, and down <name> removes it with its .env.
    • The data sits in memory (tmpfs) capped at a quarter of the box's memory, between 2 and 4 GB. --size 8g raises the cap; --disk puts the data under /work/.sbx/testdb/<name>/data instead, outside memory and without a cap. When the data area fills up, Postgres panics or the container exits: url and env then print its docker logs, and down followed by up --size 8g or --disk starts it again.
    • --redis starts Redis 7 instead (named redis unless you give a name), memory only with no saving, capped by --size like the others (a full Redis answers writes with an OOM error); its .env has REDIS_URL, TEST_REDIS_URL, REDIS_HOST and REDIS_PORT, reset empties it with FLUSHALL, and gotest does not take it. The image is in the box from images built after 2026-10-06; on older boxes the first up --redis pulls it.
    • The containers are Alpine images, so a command run inside them with docker exec gets BusyBox tools: df -Pk works, GNU options such as df --output do not.
    • psbx-testdb gotest ./... runs go test with a fresh database for every package (through go test -exec) and removes them afterwards, instead of packages clearing each other's tables in a shared one; it takes --as, --size, --disk and --mysql too, --keep leaves the databases for a look afterwards, and anything after -- goes to go test.
    • --s3 starts an S3-compatible server instead (named s3 unless you give a name; rclone serve s3, since MinIO's images and binaries can no longer be downloaded), with a bucket named after it (underscores become hyphens) already made. Its .env exports AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 (http://127.0.0.1:<port>), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION and S3_BUCKET, which the aws CLI, boto3 and the AWS SDKs read as they are; with an IP endpoint they use path-style addressing. aws s3 mb makes more buckets, reset empties them all, and --size and --disk work as for databases.
    • psbx-testdb list shows every one: name, engine, port, state, data used against its cap, creation time and owner (--owner sets it; by default the directory up ran in). up reminds you of databases left running for more than 6 hours.
  • For a service in a container, run a database container next to it and override the setting after --env-file, then run the migrations against it:

    docker network create acme
    docker run -d --name billing-db --network acme -e POSTGRES_PASSWORD=dev postgres:16-alpine
    docker run -d --name billing --network acme --env-file /work/.sbx/env/billing.env -e DATABASE_URL=postgres://postgres:dev@billing-db:5432/postgres -p 8081:8080 billing
    

Other side effects of dev settings

A copy running with dev's settings does everything dev's copy does, not only database writes. The box's billing could:

  • consume messages from dev's queues and take work away from dev's own billing;
  • run its scheduled jobs, such as monthly invoices or reminder emails, a second time;
  • register webhooks with a provider, or receive ones meant for dev;
  • send real email or SMS through the provider in its settings;
  • charge or refund through the payment provider.

In the box copy:

  • turn consumers and schedulers off with the service's own switches, set after --env-file, for example -e WORKERS_ENABLED=false -e SCHEDULER_ENABLED=false (whatever names billing reads);
  • use test-mode keys for payment, email and SMS providers, stored as ParallelSandbox secrets and passed as -e STRIPE_SECRET_KEY=$STRIPE_TEST_SECRET_KEY;
  • in a box that runs billing as a published version, pass the same switches in its env: { "name": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d", "env": { "WORKERS_ENABLED": "false", "SCHEDULER_ENABLED": "false" } } (section 4);
  • leave a broker on your network (RabbitMQ, Kafka, a Redis queue) off the connection if no box copy should consume from it: a box cannot reach what is not listed. SQS is reached over the internet with the environment's AWS role, so turn its consumers off in the service, or leave sqs:ReceiveMessage out of that role.

A changed service behind the internal load balancer

Then the agent changes admin. On dev its callers, here web's server side, reach it through the internal load balancer, internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com:80, and its container listens on 8080. In the box it runs under the load balancer's name on port 80, with a targetPort for the port your process actually listens on:

sandbox_start {
  "name": "ana: admin audit export",
  "goal": "Add the audit log export to admin; done when web's admin page downloads the export through the load balancer's name",
  "environment": "dev",
  "services": [
    { "name": "web", "port": 3000, "web": true },
    { "name": "internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com", "port": 80, "targetPort": 8082, "web": true }
  ]
}
sandbox_sync { "id": "<id>", "localPath": "/home/dev/acme/platform", "dest": "platform" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t admin ./admin && docker run -d --name admin --env-file /work/.sbx/env/admin.env -p 8082:8080 admin", "cwd": "platform", "timeoutSec": 900, "note": "build and start the changed admin, published on 8082" }
sandbox_exec { "id": "<id>", "cmd": "docker build -t web ./web && docker run -d --name web --env-file /work/.sbx/env/web.env -p 3000:3000 web", "cwd": "platform", "timeoutSec": 900, "note": "build and start the web front end with its dev settings" }
sandbox_exec { "id": "<id>", "cmd": "curl -fsS http://internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com/health", "note": "check admin answers under the load balancer's name on port 80" }
  • Callers in the box keep dialing http://internal-acme-dev-admin-1234567890.ap-northeast-1.elb.amazonaws.com/, port 80, with no setting changed, and reach the admin container published on 8082. Port 80 itself stays boxd's: without targetPort, sandbox_start fails with 400 (port 80 in the box is boxd's scene entry; add targetPort, the port your process listens on — callers keep dialing <name>:80).
  • The name is an environment address (listed on aws), so declaring it at sandbox_start takes it over: it leaves environment.reachable, and every connection to that name in the box reaches the box instead of the load balancer. Here the load balancer serves only admin; if yours routes to several services, run the ones its callers need in the box too.
  • The box has no load balancer: callers in the box reach admin's container directly under the load balancer's name, and their requests arrive as they were sent. What the load balancer does on dev does not happen here: authentication actions (ALB OIDC or Cognito), host and path rules, redirects such as HTTP to HTTPS, and the headers it adds (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Port, and x-amzn-oidc-* after authentication). Requests through admin's box URL carry the box's own X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host (Pages the person opens), but no X-Forwarded-Port or x-amzn-oidc-*. A small proxy in the box (nginx or Caddy in a container, declared under the load balancer's name on port 80 with its own targetPort, forwarding to admin on 8082) can reproduce path rules, redirects and the X-Forwarded-* headers. It cannot reproduce the login: x-amzn-oidc-data is a token the ALB signs with its own key, and an app that verifies the signature, as AWS tells apps to, rejects anything a proxy makes up. If admin sits behind an ALB that authenticates, run its box copy with the app's own dev or test login mode instead (a local user, a test identity provider, an auth switch its settings already have), set after --env-file with -e, or in env for a published version.
  • web: true on both gives each its own URL. webUrl is web's, the first web service's; the admin entry in services[] has a url of the form https://<id>-<key>-8082.box.parallelsandbox.com, 8082 being its targetPort. The person can open both; use them exactly as returned.

Public dev endpoints and externalBaseUrl

A box reaches the internet directly: https://dev.acme.example and other public dev endpoints need no setup, from programs or from containers.

externalBaseUrl is for a name you declare but do not run in the box. name:port then forwards each HTTP request to one base URL, keeping the path and rewriting Host. Example: the repo's compose file has callers of gateway:8000, which on dev is https://dev-gw.acme.example; declare { "name": "gateway", "port": 8000 } and pass "externalBaseUrl": "https://dev-gw.acme.example". A name callers dial on port 80 forwards the same way, but still needs a targetPort, like any service on port 80: { "name": "gateway", "port": 80, "targetPort": 8000 }. There is one externalBaseUrl per box, HTTP only.

With an externalBaseUrl, passed or set on the environment, every declared service starts external, including web and api.acme.internal. For each one you run in the box, start its process on its targetPort and switch the name to it; boxd does not hold the port, so the order does not matter:

sandbox_wire { "id": "<id>", "service": "api.acme.internal", "mode": "box" }
sandbox_wire { "id": "<id>", "service": "web", "mode": "box" }

Switching only rewrites routing: connections already open stay open, and names that share a targetPort switch together. While a service is external, programs on the box also reach the forwarder at localhost:<port>, except on 80 and 9095, which are boxd's own.

Callers that dial IP addresses

Some callers do not use a name: billing might get api task IPs from the Cloud Map API (DiscoverInstances) or from a list in Redis, and dial 10.0.12.34:8080. For those, declare a private range with the port at sandbox_start, as narrow as it can be, since every address in it on that port is taken over:

"services": [{ "name": "api.acme.internal", "port": 8080 }, { "name": "10.0.0.0/20", "port": 8080 }]

Connections from the box, containers included, to any address in 10.0.0.0/20 on port 8080 then reach your process in the box on the service's targetPort, here 8080 (while the service is external, the forwarder to externalBaseUrl); environment addresses inside that range on that port stop going through the connector. Ranges must be inside 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 or 100.64.0.0/10, and cannot be added later. Callers that use a Cloud Map DNS name need the name, not the range.

3. Sentry

Keep the team's Sentry; ParallelSandbox's log service is not needed for it.

  • The DSN: imported settings usually carry SENTRY_DSN (in the task definition's environment, or in a secret it references), so it is in /work/.sbx/env/api.env and reaches the container with --env-file. If it is not in the settings, store it as a ParallelSandbox secret and pass it: PUT /v1/secrets/SENTRY_DSN, "secrets": ["SENTRY_DSN"] at start (or sandbox_secrets { "id": "<id>", "names": ["SENTRY_DSN"] } later), and docker run -e SENTRY_DSN ....

  • sentry.io: the box reaches it over the internet. Nothing to set up.

  • Self-hosted Sentry in the office: the SDK sends events to the host and port in the DSN, here sentry.office.lan:443. List that host:port on the office connection, as above. TLS passes through untouched, so the SDK must trust the server's certificate as it does on dev; if it comes from your own CA, the container needs that CA, as your dev containers do.

  • Browser errors to a Sentry that only exists in the office: the browser SDK sends events straight from the person's phone or laptop, which cannot reach sentry.office.lan. Use the SDK's tunnel option (tunnel: '/sentry-tunnel' in Sentry.init): the page then posts each event to that path on web's own origin, and web's server forwards it through the box's office connection. Sentry documents the forwarding endpoint: read the first line of the request body (the envelope header), take its dsn, check that its host is sentry.office.lan and its project is yours, and POST the body to https://sentry.office.lan/api/<project id>/envelope/. @sentry/nextjs has a tunnelRoute option that does this for you, but Sentry's docs say it does not work with self-hosted Sentry, so write the route yourself. admin, the second browser app, does the same with a route of its own on its own origin: its pages reach only admin's server through its URL, so admin forwards its own events.

  • Mark what came from a box. Sentry's server SDKs (Python, Node, Go, Java, .NET, PHP) take SENTRY_ENVIRONMENT and SENTRY_RELEASE from the environment unless your code passes its own values to Sentry.init, so -e SENTRY_ENVIRONMENT=psbx -e SENTRY_RELEASE=<commit> (or, for a published version, the same names in its env) needs no code change: dev dashboards and alerts that filter on your dev environment stay clean, and the release says which build the box ran. To tell boxes apart, pass -e SBX_BOX_ID and add it as a tag where your code initialises Sentry, for example initialScope: { tags: { psbx_box: process.env.SBX_BOX_ID } } in JavaScript or sentry_sdk.set_tag("psbx_box", os.environ.get("SBX_BOX_ID")) in Python. Browser bundles get environment and release from Sentry.init at build time; set them in the box's build.

  • Reading the errors: logs_search, logs_errors and logs_tail read only ParallelSandbox's log service, never Sentry. Use Sentry's own access with a read-only auth token, kept on the agent's machine or stored as a ParallelSandbox secret (SENTRY_AUTH_TOKEN) when the agent queries from a box: Sentry's API, for example curl -s -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" "https://sentry.io/api/0/organizations/<org>/issues/?project=<project id>&environment=psbx&statsPeriod=24h&query=psbx_box:<box id>%20release:<commit>", which lists the issues that one box raised with that build (self-hosted: the same path on your Sentry host, which a box reaches through office), sentry-cli, or Sentry's MCP server if your agent has one.

  • Browser pages opened through the box's URLs carry the box's key in every URL Sentry records (the page URL, breadcrumbs, stack frames). Remove it in beforeSend the way @parallelsandbox/log does, and tag events with the box id from the same host:

    const m = /^([a-z0-9]{6,32})-([a-z0-9]{8,32})(?:-[0-9]{1,5})?\.box\.parallelsandbox\.com$/.exec(location.hostname);
    const scrub = (event) => (m ? JSON.parse(JSON.stringify(event).split(`${m[1]}-${m[2]}`).join(m[1])) : event);
    
    Sentry.init({
      dsn: '<browser DSN>',
      environment: 'psbx',
      release: '<commit>',
      initialScope: m ? { tags: { psbx_box: m[1] } } : undefined,
      beforeSend: scrub,
      beforeSendTransaction: scrub,
    });
    

    <id>-<key> is only letters, digits and dashes, so replacing it in the serialised event cannot break the JSON: web's https://<id>-<key>.box.parallelsandbox.com/cart (its url is sceneUrl, as it is declared first) is recorded as https://<id>.box.parallelsandbox.com/cart, and admin's https://<id>-<key>-8082.box.parallelsandbox.com/ as https://<id>-8082.box.parallelsandbox.com/. The psbx_box tag holds the same box id the services send, so one query finds both.

  • Source maps for a browser build made in the box, so Sentry shows original code for the box's release. This is standard Sentry usage, nothing specific to ParallelSandbox: build web on the box with browser source maps (Next.js: productionBrowserSourceMaps: true), inject debug IDs, and upload them under the same release that Sentry.init uses. Store an auth token that may upload source maps as the secret SENTRY_AUTH_TOKEN and pass it at start ("secrets": ["SENTRY_AUTH_TOKEN"]) or later with sandbox_secrets:

    cd /work/platform/web && . /work/.sbx/env/web.sh && npm ci && npx next build
    export SENTRY_ORG=<org slug> SENTRY_PROJECT=<browser project slug>   # self-hosted: also SENTRY_URL=https://sentry.office.lan/
    npx @sentry/cli sourcemaps inject .next/static
    npx @sentry/cli sourcemaps upload --release a1b2c3d .next/static
    

    Inject before the built files are served, then start web from this build. The upload goes to sentry.io over the internet; a self-hosted Sentry is reached through office, where sentry.office.lan:443 is listed.

  • @parallelsandbox/log next to Sentry is worth it for browser pages when the agent should read their console output, not only errors, filtered by box with logs_* and without a Sentry token. It is browser-only and does not replace Sentry for services. See Log SDK.

4. Use the changed billing from another box

The changed billing runs in box 1 (section 2). Another box, box 2, started by the same agent or by a teammate's, can use it without rebuilding, in two ways:

  • Recommended: run billing's published version in box 2. Publish box 1's billing image as soon as it builds (below) and start box 2 with version on billing.acme.internal from the beginning, with env for its Sentry marks and to keep its workers off. Box 2 then runs its own copy whether box 1 still runs or not, and a newer build is one sandbox_wire … version away.
  • A link to box 1, when box 2 needs box 1's live billing (its data so far, a debugger attached). It works only while box 1 runs. When box 1 is about to go away, box 2 can switch the name to the published version in place and keep its URLs (below); a box 2 started before 2026-09-25 12:40 UTC, when that switch shipped, needs a new box 2, with new URLs, instead.
sandbox_start {
  "name": "ben: web against the refund billing",
  "goal": "Build web's refund screen against ana's changed billing in box 1; done when a refund made in the browser follows the new refund rules",
  "environment": "dev",
  "services": [
    { "name": "web", "port": 3000, "web": true },
    { "name": "api.acme.internal", "port": 8080 },
    { "name": "billing.acme.internal", "port": 8080, "fromBox": "<box 1>" }
  ]
}
  • <box 1> is box 1's id. An agent that did not start box 1, a teammate's say, finds it with sandbox_list, by the box's name, goal and services.
  • In box 2, billing.acme.internal:8080 reaches the billing running in box 1, from box 2's programs and containers alike. fromPort is left out, so it is the targetPort of box 1's billing.acme.internal, 8081.
  • billing.acme.internal is also an environment address. The link wins, so box 2 reaches box 1's copy instead of dev's, and the name leaves box 2's environment.reachable.
  • Box 2 runs web and api itself, as in section 2; they keep calling billing.acme.internal:8080.
  • Box 1 stays awake while box 2 has connections open to it, and a frozen box 1 is thawed by the next connection. When box 1 stops, box 2's connections to it fail with 410 (… start a new box and re-point the link with sandbox_wire), and sandbox_stop on box 1 lists box 2 under linkedFrom. To point the name at another box: sandbox_wire { "id": "<box 2>", "service": "billing.acme.internal", "port": 8080, "fromBox": "<new box>" }.
  • When box 1 is about to go away, switch box 2 to billing's published version in place: sandbox_wire { "id": "<box 2>", "service": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d", "env": { "SENTRY_ENVIRONMENT": "psbx", "WORKERS_ENABLED": "false" } }. On a box whose sandbox_status → health.features includes take-over (every box started from 2026-09-25 12:40 UTC), the version takes the name over: it keeps its address, box 2's open connections to box 1 are closed and its programs reconnect to the version, the record stops being a link, box 1's linkedFrom drops box 2, and box 2 keeps its URLs. On an older box 2 this fails with 409 (<name> is a link in this box, and box <id> runs boxd <version>, which cannot turn a link into a published version. Start a new box that declares the version, or wire the link again with fromBox or fromPort): re-point the link to a box that runs billing, as above, or start a new box 2 that declares the version (next section).
  • When box 2 itself thaws after a freeze, its connections to box 1 (and to dev) that were open before the freeze are closed; its programs have to reconnect.
  • When box 2 freezes or stops, ParallelSandbox closes its connections to box 1 within about a minute, and box 1 then freezes on its own idle time. While box 2 is awake and holds connections, box 1 stays awake.
  • Link traffic passes through ParallelSandbox and counts as outbound traffic of whichever box sends it: box 2's requests are box 2's, box 1's replies are box 1's. sandbox_status shows box 2's links[] and box 1's linkedFrom[]. The details are in Links to other boxes.

After box 1 is gone: run the published version

A link needs box 1 running. To keep the build after box 1 stops, publish its image first, from box 1:

sandbox_status { "id": "<box 1>" }

registry.imagePrefix is the exact prefix, <registry>/parallelsandbox/tenant-<account>:. Every box of the account is already logged in to that registry, to push and to pull, and stays logged in while it runs. There is one repository per account, so the tag carries the service:

sandbox_exec { "id": "<box 1>", "cmd": "docker tag billing '<imagePrefix>billing-a1b2c3d' && docker push '<imagePrefix>billing-a1b2c3d'", "timeoutSec": 900, "note": "push the billing build for other boxes" }
sandbox_publish_version { "service": "billing", "label": "refund-a1b2c3d", "image": "<imagePrefix>billing-a1b2c3d", "gitSha": "a1b2c3d", "note": "refund rules" }

image must start with imagePrefix (400 otherwise) and is not checked for existence, so push first; label is unique per service (409 when reused). The push is outbound traffic from the box and is metered. The registry has no expiry rule: images stay until the account is deleted. Pushing an existing tag again replaces its image, a box's registry login can push and pull but not delete, and DELETE /v1/versions/{id} removes only the version record.

In box 2, declare the version under billing's dev name instead of a link; the box pulls and runs it:

sandbox_start {
  "name": "ben: web on the refund billing",
  "goal": "Build web's refund screen against billing's published refund version; done when a refund made in the browser follows the new refund rules",
  "environment": "dev",
  "services": [
    { "name": "web", "port": 3000, "web": true },
    { "name": "api.acme.internal", "port": 8080 },
    { "name": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d",
      "env": { "SENTRY_ENVIRONMENT": "psbx", "SENTRY_RELEASE": "a1b2c3d", "WORKERS_ENABLED": "false", "SCHEDULER_ENABLED": "false" } }
  ]
}
sandbox_status { "id": "<box 2>" }
  • sandbox_start returns at once. When box 2 is ready it pulls the image and starts it as the container psbx-svc-billing-acme-internal. Call sandbox_status until billing's services[].run.state is running; failed carries the error and the end of the log.
  • Declaring the version at sandbox_start, as here, works on every box. billing.acme.internal is an environment address: on a running box, sandbox_wire with version takes it over too when the box has the take-over feature; on older boxes the version starts but the name keeps going to the connector.
  • To move box 2 to a newer build of billing, publish it under a new label and call sandbox_wire { "id": "<box 2>", "service": "billing.acme.internal", "port": 8080, "version": "<new label>" }. A name declared with version at start can be switched this way, environment address or not: the container is replaced, run starts again from starting, and callers keep dialing billing.acme.internal:8080.
  • api already uses port 8080 in box 2, so the container is published on 20000, the first free port from 20000; callers keep dialing billing.acme.internal:8080, from box 2's programs and containers alike. Inside, the container listens on the port its image EXPOSEs, or on 8080 when it exposes none.
  • The version was published for the service billing, so the container gets billing's dev settings, /work/.sbx/env/billing.env, then the env given here (a name in both takes the env value), plus SBX_BOX_ID. Its Sentry events are marked psbx with the release (section 3), and its workers and schedulers stay off (Other side effects of dev settings). The env values show in sandbox_status, so keep secrets out of them.
  • refund-a1b2c3d is a label. If another service also has a version labelled refund-a1b2c3d, the name's first label, billing, picks this one; otherwise pass the versionId from sandbox_versions.

Declaring billing.acme.internal in box 2 is what makes its callers reach the image it runs. GET /v1/versions and DELETE /v1/versions/{id} manage versions over REST. The rules are in Run a published version.

Not possible:

  • A link to a box of another account, or a link by address or range: links connect boxes of one account, by host name.
  • Reaching box 1 after it has stopped: re-point the link to a running box, or run the published version.
  • A version is an image and nothing else: /work, container state and database contents do not carry over.

5. Troubleshooting

  • Box 1 does not freeze: another box holds link connections to it (box 1's health shows servedLinks). They close within about a minute after that box freezes or stops; sandbox_status on box 1 lists the linked boxes in linkedFrom.
  • sandbox_environments → connections[]: which connection is offline, since when (lastSeenAt), its connector's version, and the addresses it carries. connectorOnline is true when any connection is up, so it does not show an offline office on its own. The final check is a real client on one of its addresses (the check above).
  • sandbox_status → health.privateEndpoints[]: for each address host, port, localIp and localPort (the entry boxd opens for it inside the box, not on the address's own port), active, opened, failed and lastError.
  • /work/.sbx/logs/private-endpoints.log: one line per failed connection, with the reason.
  • 403: the address is no longer listed on any connection of the environment; add it back. An address added after the box started is unknown to that box; start a new one.
  • 503: the connector of the connection that lists the address is offline, and the message names that connection: docker logs parallelsandbox-connector on the office host, or the log group /ecs/parallelsandbox-connector in AWS. The client in the box sees its connection accepted and then reset.
  • 502: the connector is up but cannot reach the target: DNS, routes or security groups between the connector and that address.
  • A declared name still reaches dev: it is an environment address that was added with sandbox_wire on a running box. Declare it at sandbox_start.
  • A connection times out, and nothing is logged: the address is a private IP that no connection lists. Could not resolve host: the name is neither declared nor an environment address. Refused at once: a declared name or environment host on a port nobody declared, with nothing listening on that port in the box.
  • A link fails: sandbox_status.links[].lastError and private-endpoints.log say why. 410: the other box stopped; re-point the link with sandbox_wire. 409: the other box is still starting, or runs an image from before links. 502 nothing listens on port …: the process in the other box is not up on fromPort.
  • aws ecr get-login-password fails with AccessDeniedException: the environment's AWS role may not pull from ECR (Run dev's unchanged callers in the box).
  • Connection errors right after a box thaws: boxd closes the connections to environment addresses and links that were open before the freeze (within about 15 seconds of the thaw), so programs get an error instead of a socket that never answers. Database pools usually reconnect on their own; long-lived clients, such as message consumers and WebSocket or gRPC streams, have to retry.
  • 502 nothing listens on <name>:80 in this box: that name is declared on another port. …: the caller dials the name on port 80, but the name is declared (or listed) on another port. Dial the port it is declared on, or declare the name on port 80 with a targetPort. On a box started before 2026-09-24 20:30 UTC the same request reaches the first declared service instead.
  • A service in the box does not answer by its name: it must listen on 0.0.0.0 on its targetPort, and a container must publish that port (-p <targetPort>:<container port>). sandbox_status.wiring.services[] shows each name's port, targetPort, address and mode; with an externalBaseUrl, check that it is box.
  • sandbox_start fails with port 80 in the box is boxd's scene entry; add targetPort, the port your process listens on — callers keep dialing <name>:80: add targetPort to that service. sandbox_wire saying the box's boxd does not support targetport: the box started before targetPort shipped; start a new box.
  • A box URL answers 404: the key is wrong, or the -<port> has no web service behind it. Take the URLs from sandbox_status as they are.
  • The person's browser shows dev's data, not the box's: the front end calls an absolute API URL (Pages the person opens).

What this setup cannot do

  • Reach a box from your networks. Services on dev cannot call a copy running in a box; only programs in the box see it.
  • Link to a box of another account, or keep using a box's service after that box stops: links connect running boxes of one account, and fail with 410 once the other box is gone.
  • Match addresses with wildcards or ranges on a connection: every host:port is listed exactly, at most 100 per connection and 200 per box.
  • Send different declared names to different external URLs: there is one externalBaseUrl per box, and it forwards HTTP only.
  • Listen on port 80 or 9095 in a box: boxd holds them. Callers can still dial a name on port 80, reaching the process on its targetPort.
  • Open a service from outside the box unless it is marked web or is the first declared service.
  • Read Sentry with logs_*: those tools read only ParallelSandbox's own log service.