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/keyswith a name; shown once), lists them withGET /v1/keysand revokes one withDELETE /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.creditsshows 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_startfails for everyone withplan <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_startwithwaitForCapacitySecwaits 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'sfromBox, 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_stopthe boxes it is done with instead of leaving them frozen, unless it hands one to the person withsandbox_review: a frozen box costs nothing but keeps its slot until it is stopped, 24 hours after its last use unlessidleTimeoutMinis 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'snamesays whose it is, and itsgoalwhat it is for. Agents should name boxes<person>: <task>and put the rest ingoal, stop only boxes they started (they keep the idssandbox_startreturned) 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_saymessages (and lets them leave notes for the agent). - Teammates who are not signed in get
webUrland eachservices[].urlfrom their own agent (sandbox_start,sandbox_statusorsandbox_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_listshows each box'sagent.state, andleftoridlemarks one nobody is working on; they tell their agent to pick it up bynameorid(Picking up a box another conversation left). takeoverUrlopens the box's page in the app and needs the account's sign-in. The linksandbox_takeovercreates (…/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:GetParameterson that parameter, andkms:Decrypton the KMS key when the SecureString is encrypted with a customer-managed key (the defaultaws/ssmkey 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.awsand connects toapi.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.internalresolve 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/importwith{"region":"ap-northeast-1","cluster":"acme-dev","service":"api"}, once per service):api,billing,webandadmin. This needs the read-only role from Import service settings from AWS, created once by whoever holds the AWS account. Plainenvironmentvalues are copied, SSM and Secrets Manager references are resolved;environmentFilesin S3 are not read and come back insourceRef.skipped. Each becomes/work/.sbx/env/<service>.envand.shin a box. - If a service keeps part of its settings in an S3
environmentFilesfile, upload that file's contents under a second name (PUT /v1/environments/dev/services/api-fileswith{"dotenv": "…"}), for exampleapi-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
externalBaseUrlempty 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
servicesgets 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 ofreachablefor 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 throughapi, run the callers you need in the box too (Run dev's unchanged callers in the box). portinservicesis the port callers dial, andname:portreaches your process ontargetPort, which defaults toport. Callers keep the address they use on dev, and the process can listen on another port: two changed services that both listen on 8080 keepapi.acme.internal:8080andbilling.acme.internal:8080, each with its owntargetPort(see A second changed service on the same port).- The process must listen on
0.0.0.0, on itstargetPort. One bound to127.0.0.1is 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). WithouttargetPort,sandbox_startfails 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 withsandbox_wireon a box whosesandbox_status→health.featuresincludestake-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.0in 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 aninternal-…elb.amazonaws.comname, 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 example10.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
targetPortfor 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 502nothing 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:80is 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, answers404 page not found.
- A declared name or an environment host dialed on another port stays in the box: whatever listens on that port on
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: truegivesweba URL the person can open. Its URL is the result'swebUrl, the first web service's URL (here alsosceneUrl, becausewebhappens 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 whereverwebsits in the list.apiis not markedweb, so it has no URL and nothing outside the box can reach it.api.acme.internalis the exact nameweband the other services use forapi, sowebin the box reachesapiin 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.namestarts with the person the box is for, so teammates' agents can tell whose it is insandbox_list.goalsays 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 insandbox_status.- Programs in the box and containers on any Docker network, compose included, reach
api.acme.internaland the environment's addresses the same way. - The result's
environment.reachableno longer listsapi.acme.internal, and still lists RDS, Redis,billing.acme.internal, the internal load balancer,ledger.office.lan,10.20.0.15andsentry.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.
apiexpects/api/orders(prefix kept): Next.jsrewrites(){ source: '/api/:path*', destination: 'http://api.acme.internal:8080/api/:path*' }; Viteserver: { proxy: { '/api': 'http://api.acme.internal:8080' } }.apiexpects/orders(prefix stripped): Next.jsrewrites(){ source: '/api/:path*', destination: 'http://api.acme.internal:8080/:path*' }; Viteserver: { 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
webUrlorservices[].urlright aftersandbox_startand removes it beforesandbox_stop. Examples: Amazon Cognitoaws 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 APIPATCH /api/v2/clients/{id}withcallbacks, and Keycloak's admin APIPUT /admin/realms/{realm}/clients/{id}withredirectUris.
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
apion Graviton (runtimePlatform.cpuArchitecture: ARM64in its task definition) and the image has no amd64 variant,docker runfails withexec 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, andecr:BatchCheckLayerAvailability,ecr:GetDownloadUrlForLayerandecr:BatchGetImageon the repository (the AWS managed policyAmazonEC2ContainerRegistryReadOnlyhas all four). Without them the login fails withAccessDeniedException … 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', thenaws 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
apiis 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>.envasexportlines:DATABASE_URL,TEST_DATABASE_URL,TEST_POSTGRES_URI(Postgres), thePG*variables (sopsqljust works) and Laravel'sDB_*; for MySQL,MYSQL_*in place ofPG*. It prints one line per database and the. /work/.sbx/testdb/<name>.envto run, not the connection string;psbx-testdb url <name>prints that, password included. It listens on127.0.0.1in the box: programs running on the box reach it, a container only with--network host. The rest, frompsbx-testdb --help:- Tests that read another variable find nothing, skip, and look like passes (all
SKIP, orokin 0.0x seconds):--as NAME(repeatable) writes the connection under that name too, for examplepsbx-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, anddown <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 8graises the cap;--diskputs the data under/work/.sbx/testdb/<name>/datainstead, outside memory and without a cap. When the data area fills up, Postgres panics or the container exits:urlandenvthen print itsdocker logs, anddownfollowed byup --size 8gor--diskstarts it again. --redisstarts Redis 7 instead (namedredisunless you give a name), memory only with no saving, capped by--sizelike the others (a full Redis answers writes with an OOM error); its.envhasREDIS_URL,TEST_REDIS_URL,REDIS_HOSTandREDIS_PORT,resetempties it withFLUSHALL, andgotestdoes not take it. The image is in the box from images built after 2026-10-06; on older boxes the firstup --redispulls it.- The containers are Alpine images, so a command run inside them with
docker execgets BusyBox tools:df -Pkworks, GNU options such asdf --outputdo not. psbx-testdb gotest ./...runsgo testwith a fresh database for every package (throughgo test -exec) and removes them afterwards, instead of packages clearing each other's tables in a shared one; it takes--as,--size,--diskand--mysqltoo,--keepleaves the databases for a look afterwards, and anything after--goes togo test.--s3starts an S3-compatible server instead (nameds3unless you give a name; rcloneserve s3, since MinIO's images and binaries can no longer be downloaded), with a bucket named after it (underscores become hyphens) already made. Its.envexportsAWS_ENDPOINT_URL,AWS_ENDPOINT_URL_S3(http://127.0.0.1:<port>),AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGIONandS3_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 mbmakes more buckets,resetempties them all, and--sizeand--diskwork as for databases.psbx-testdb listshows every one: name, engine, port, state, data used against its cap, creation time and owner (--ownersets it; by default the directoryupran in).upreminds you of databases left running for more than 6 hours.
- Tests that read another variable find nothing, skip, and look like passes (all
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 namesbillingreads); - 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
billingas a published version, pass the same switches in itsenv:{ "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:ReceiveMessageout 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 theadmincontainer published on 8082. Port 80 itself stays boxd's: withouttargetPort,sandbox_startfails 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 atsandbox_starttakes it over: it leavesenvironment.reachable, and every connection to that name in the box reaches the box instead of the load balancer. Here the load balancer serves onlyadmin; 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, andx-amzn-oidc-*after authentication). Requests throughadmin's box URL carry the box's ownX-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Host(Pages the person opens), but noX-Forwarded-Portorx-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 owntargetPort, forwarding toadminon 8082) can reproduce path rules, redirects and theX-Forwarded-*headers. It cannot reproduce the login:x-amzn-oidc-datais 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. Ifadminsits 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-filewith-e, or inenvfor a published version. web: trueon both gives each its own URL.webUrlisweb's, the first web service's; theadminentry inservices[]has aurlof the formhttps://<id>-<key>-8082.box.parallelsandbox.com, 8082 being itstargetPort. 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.envand 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 (orsandbox_secrets { "id": "<id>", "names": ["SENTRY_DSN"] }later), anddocker 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 thathost:porton theofficeconnection, 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'stunneloption (tunnel: '/sentry-tunnel'inSentry.init): the page then posts each event to that path onweb's own origin, andweb's server forwards it through the box'sofficeconnection. Sentry documents the forwarding endpoint: read the first line of the request body (the envelope header), take itsdsn, check that its host issentry.office.lanand its project is yours, and POST the body tohttps://sentry.office.lan/api/<project id>/envelope/.@sentry/nextjshas atunnelRouteoption 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 onlyadmin's server through its URL, soadminforwards its own events.Mark what came from a box. Sentry's server SDKs (Python, Node, Go, Java, .NET, PHP) take
SENTRY_ENVIRONMENTandSENTRY_RELEASEfrom the environment unless your code passes its own values toSentry.init, so-e SENTRY_ENVIRONMENT=psbx -e SENTRY_RELEASE=<commit>(or, for a published version, the same names in itsenv) 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_IDand add it as a tag where your code initialises Sentry, for exampleinitialScope: { tags: { psbx_box: process.env.SBX_BOX_ID } }in JavaScript orsentry_sdk.set_tag("psbx_box", os.environ.get("SBX_BOX_ID"))in Python. Browser bundles getenvironmentandreleasefromSentry.initat build time; set them in the box's build.Reading the errors:
logs_search,logs_errorsandlogs_tailread 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 examplecurl -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 throughoffice),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
beforeSendthe way@parallelsandbox/logdoes, 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'shttps://<id>-<key>.box.parallelsandbox.com/cart(itsurlissceneUrl, as it is declared first) is recorded ashttps://<id>.box.parallelsandbox.com/cart, andadmin'shttps://<id>-<key>-8082.box.parallelsandbox.com/ashttps://<id>-8082.box.parallelsandbox.com/. Thepsbx_boxtag 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
webon the box with browser source maps (Next.js:productionBrowserSourceMaps: true), inject debug IDs, and upload them under the same release thatSentry.inituses. Store an auth token that may upload source maps as the secretSENTRY_AUTH_TOKENand pass it at start ("secrets": ["SENTRY_AUTH_TOKEN"]) or later withsandbox_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/staticInject before the built files are served, then start
webfrom this build. The upload goes to sentry.io over the internet; a self-hosted Sentry is reached throughoffice, wheresentry.office.lan:443is listed.@parallelsandbox/lognext to Sentry is worth it for browser pages when the agent should read their console output, not only errors, filtered by box withlogs_*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
billingimage as soon as it builds (below) and start box 2 withversiononbilling.acme.internalfrom the beginning, withenvfor 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 onesandbox_wire … versionaway. - 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.
While box 1 runs: link to it
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 withsandbox_list, by the box'sname,goalandservices.- In box 2,
billing.acme.internal:8080reaches thebillingrunning in box 1, from box 2's programs and containers alike.fromPortis left out, so it is thetargetPortof box 1'sbilling.acme.internal, 8081. billing.acme.internalis 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'senvironment.reachable.- Box 2 runs
webandapiitself, as in section 2; they keep callingbilling.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), andsandbox_stopon box 1 lists box 2 underlinkedFrom. 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 whosesandbox_status→health.featuresincludestake-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'slinkedFromdrops 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_statusshows box 2'slinks[]and box 1'slinkedFrom[]. 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_startreturns at once. When box 2 is ready it pulls the image and starts it as the containerpsbx-svc-billing-acme-internal. Callsandbox_statusuntil billing'sservices[].run.stateisrunning;failedcarries the error and the end of the log.- Declaring the version at
sandbox_start, as here, works on every box.billing.acme.internalis an environment address: on a running box,sandbox_wirewithversiontakes it over too when the box has thetake-overfeature; 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 withversionat start can be switched this way, environment address or not: the container is replaced,runstarts again fromstarting, and callers keep dialingbilling.acme.internal:8080. apialready uses port 8080 in box 2, so the container is published on 20000, the first free port from 20000; callers keep dialingbilling.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 theenvgiven here (a name in both takes theenvvalue), plusSBX_BOX_ID. Its Sentry events are markedpsbxwith the release (section 3), and its workers and schedulers stay off (Other side effects of dev settings). Theenvvalues show insandbox_status, so keep secrets out of them. refund-a1b2c3dis a label. If another service also has a version labelledrefund-a1b2c3d, the name's first label,billing, picks this one; otherwise pass theversionIdfromsandbox_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_statuson box 1 lists the linked boxes inlinkedFrom. sandbox_environments→connections[]: which connection is offline, since when (lastSeenAt), its connector's version, and the addresses it carries.connectorOnlineistruewhen any connection is up, so it does not show an offlineofficeon its own. The final check is a real client on one of its addresses (the check above).sandbox_status→health.privateEndpoints[]: for each addresshost,port,localIpandlocalPort(the entry boxd opens for it inside the box, not on the address's own port),active,opened,failedandlastError./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-connectoron the office host, or the log group/ecs/parallelsandbox-connectorin 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_wireon a running box. Declare it atsandbox_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[].lastErrorandprivate-endpoints.logsay why. 410: the other box stopped; re-point the link withsandbox_wire. 409: the other box is still starting, or runs an image from before links. 502nothing listens on port …: the process in the other box is not up onfromPort. aws ecr get-login-passwordfails withAccessDeniedException: 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 atargetPort. 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.0on itstargetPort, and a container must publish that port (-p <targetPort>:<container port>).sandbox_status.wiring.services[]shows each name'sport,targetPort,addressandmode; with anexternalBaseUrl, check that it isbox. sandbox_startfails withport 80 in the box is boxd's scene entry; add targetPort, the port your process listens on — callers keep dialing <name>:80: addtargetPortto that service.sandbox_wiresaying the box's boxd does not supporttargetport: the box started beforetargetPortshipped; start a new box.- A box URL answers 404: the key is wrong, or the
-<port>has nowebservice behind it. Take the URLs fromsandbox_statusas 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:portis listed exactly, at most 100 per connection and 200 per box. - Send different declared names to different external URLs: there is one
externalBaseUrlper 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
webor is the first declared service. - Read Sentry with
logs_*: those tools read only ParallelSandbox's own log service.