Sandboxes
Start Services Automatically with environment.json
When your Cloud Sandbox sleeps and wakes, your files and installed dependencies come back exactly as you left them — but anything you had running does not. A dev server, a file watcher, a bun dev you started by hand all stop when the sandbox sleeps, and you'd normally have to start them again by hand on the next wake.
environment.json fixes that. Drop a small environment.json file in your repository describing what should be running, and RepoGo starts it for you automatically — every time the environment boots or wakes. Each service runs in its own terminal that you can open, watch, stop, and restart right from the app.
Think of it as: "Here's what 'on' looks like for this project." You describe it once, commit it, and it just comes up.
No file yet? You don't have to leave the app. The web dashboard has an Environment tab in the workspace panel: it lists every service your environment.json declares, shows which are actually running, and lets you stop one or kill them all. With no file yet, it offers a Create environment.json button — one click writes a starter file at the repo root and opens it in the editor. Once a file exists, the pencil in the tab header reopens it.
A quick example#
Create a file named environment.json at the root of your repository:
{
"services": [{ "name": "dev", "cmd": "bun install && bun dev" }]
}That's it. The next time your environment wakes, RepoGo finds this file, runs bun install && bun dev, and opens a terminal tab named dev with the live output. Your dev server is back up before you even open the app.
Install before you start. Chain your install step into
cmdwith&&(e.g.bun install && bun dev). A freshly cloned repo — or a cold boot before dependencies are cached — won't havenode_modulesyet, so install first. It's a fast no-op once deps are already there.
How it works#
- You commit
environment.jsonto your repo. It lives with your code, so it's the same on every machine and every clone, and it can differ per branch. - RepoGo scans your repos on boot. Every time your environment wakes, RepoGo looks through the repositories on it and reads each one's
environment.json. - Each service starts in its own terminal. Every service you listed comes up as a named terminal tab. You can open any tab to watch its output live, type into it, stop it, or restart it.
- It all comes back on the next wake. When the environment sleeps and wakes again, RepoGo brings every service back up automatically — same terminals, same names, fresh and running.
Because environment.json is committed to your repo, there's nothing to set up in the app and nothing to remember. Onboarding a new project is just: add the file, commit, done.
The file in full#
environment.json has one required field, services — a list of the things to run. Each service looks like this:
{
"version": 1,
"services": [
{
"name": "api",
"cmd": "bun install && bun run start",
"target": "8080",
"expose": true,
"envFrom": ["api-secrets"]
},
{
"name": "web",
"cmd": "bun install && bun dev",
"cwd": "apps/frontend",
"target": "3000",
"expose": true,
"dependsOn": ["api"],
"env": { "NEXT_PUBLIC_API_URL": "${ENVIRONMENT_URL_API}" }
}
]
}Fields#
| Field | Required | What it does |
|---|---|---|
name | Yes | A short label for the service. It becomes the terminal tab's name and is how other services refer to it, so make it recognizable — web, api, worker. Use lowercase letters, numbers, and dashes. |
type | No | "service" (default) for long-running processes, or "setup" for run-once steps that exit — installs, migrations, codegen. Anything that dependsOn a setup waits for it to finish before starting. |
cmd | Yes | The command to run. It runs in a shell, so chain your install step with && — e.g. bun install && bun dev, npm install && npm start. Pipes, $VARS, and scripts (bash scripts/dev.sh) work too. |
cwd | No | The folder to run the command in, relative to your repo root. Leave it out to run at the repo root. Useful for monorepos: apps/frontend. |
target | No | Where your service serves locally — either a port number as a string ("3000") or a hostname your local proxy routes ("web.localhost"). Declaring it lets other services reach this one (see Connecting services) and is what expose publishes. On its own it does not open anything to the internet. (port is still accepted as an alias.) |
expose | No | true opens this service's target to the internet on boot, giving it a public URL. Defaults to false — services are private unless you opt in. Requires target. |
readyWhen | No | A readiness check so dependents wait until this service is actually serving, not just started: { "port": 8080 } (TCP open) or { "http": "http://localhost:8080/health" }. Add "timeoutMs" to cap the wait. Optional — without it, dependents start as soon as this one spawns. |
env | No | Extra environment variables for this service, as name/value pairs. You can reference another service's address here (see Connecting services). |
envFrom | No | A list of named secret sources to load before starting, e.g. ["api-secrets"]. You point each name at a file on your Mac once, in the app — the repo never contains your secrets or file paths (see Secrets, kept on your Mac). |
dependsOn | No | Other services to start first. Depending on a setup waits for it to exit; depending on a service waits for it to spawn — or to be ready, if that service has readyWhen. |
autoStart | No | Defaults to true. Set false to define a service without starting it automatically — it shows as a stopped tab you can start by hand. |
autoRestart | No | If true, RepoGo restarts the service if its process exits. Off by default — if a service fails to start, it stays stopped and you restart it yourself. |
idleStop | No | What happens to this service once nobody is viewing the workspace. Leave it out and it's stopped after a 3-minute grace period. "now" stops it the moment you leave; "never" keeps it running until you stop it yourself. See When services stop on their own. |
version | No | The config format version. Use 1. You can leave it out. |
Examples#
One file format covers a single app, a monorepo, or multiple apps that depend on each other:
Single app#
A Next.js app at the repo root. Install, run, expose:
{
"services": [{ "name": "web", "cmd": "npm install && npm run dev", "target": "3000", "expose": true }]
}Monorepo (shared dependencies)#
Workspaces (pnpm/npm/yarn, Turborepo) install once at the root. A type: "setup" step runs the install; the apps dependsOn it and start only after it finishes. readyWhen makes web wait until api is actually serving:
{
"services": [
{ "name": "install", "type": "setup", "cmd": "pnpm install" },
{
"name": "api",
"cmd": "pnpm --filter api dev",
"target": "8080",
"expose": true,
"dependsOn": ["install"],
"readyWhen": { "port": 8080 }
},
{
"name": "web",
"cmd": "pnpm --filter web dev",
"target": "3000",
"expose": true,
"dependsOn": ["install", "api"],
"env": { "NEXT_PUBLIC_API_URL": "${ENVIRONMENT_URL_API}" }
}
]
}One install, ordered start, no race.
Multiple apps (isolated dependencies)#
Separate apps that each manage their own dependencies — no shared install. Each installs itself in its own cwd; web still waits for api to be serving and reads its URL:
{
"services": [
{ "name": "api", "cmd": "npm install && npm run dev", "cwd": "api", "target": "8080", "expose": true, "readyWhen": { "port": 8080 } },
{
"name": "web",
"cmd": "npm install && npm run dev",
"cwd": "web",
"target": "3000",
"expose": true,
"dependsOn": ["api"],
"env": { "NEXT_PUBLIC_API_URL": "${ENVIRONMENT_URL_API}" }
}
]
}cwdis relative to the repo root — oneinstallper app, in its own folder.dependsOnwaits for spawn;readyWhenwaits for serving; asetupis always waited on until it exits.${ENVIRONMENT_URL_API}is api's public URL (it's exposed). Don't want it public? Dropexposeand use${ENVIRONMENT_TARGET_API}→http://localhost:$ENVIRONMENT_TARGET_API(for a port target).
Public URLs (you opt in)#
By default, nothing your services run is exposed to the internet. A service starts, runs, and is reachable only inside your environment — until you opt in.
To make a service public, give it a target and set expose: true:
{ "name": "web", "cmd": "bun install && bun dev", "target": "3000", "expose": true }On boot, RepoGo opens and enables that target and assigns the service a unique public address — something like https://yourmac-3000-x7k2.repogo.dev — with no port number in the URL. RepoGo generates the subdomain for you; you don't choose it (so names never collide between users). Once assigned, the address stays the same across sleeps and restarts, so you can bookmark or share it.
targetalone → not public. It just tells RepoGo (and your other services) where this one serves. Nothing is opened.expose: true→ public. This flag is the explicit opt-in; it defaults tofalse, so RepoGo never opens a target you didn't ask for. It also doesn't watch your processes and expose whatever they happen to listen on — exposure only happens when you setexpose: true(or open one yourself in the app).- Comes back on every wake. Because
expose: trueis in your committed config, RepoGo re-opens and re-enables the target each time the environment boots — you don't have to remember to turn it back on. - You can still open or close targets any time from the environment's screen in the app, independent of
environment.json.
Portless: friendly hostnames#
A target doesn't have to be a port number. If your dev setup serves apps on hostnames instead — like web.localhost and api.localhost — set target to the hostname and RepoGo proxies straight to it. This is what we call portless, and it's the cleanest way to run several apps in one environment.
{
"services": [
{ "name": "web", "cmd": "bun install && bun dev", "target": "web.localhost", "expose": true },
{ "name": "api", "cmd": "bun install && bun start", "target": "api.localhost", "expose": true }
]
}Why it's nice:
- No port collisions. Two apps that both want
3000can't clash when each one owns a name. You can run a frontend, an API, and a worker side by side in the same sandbox without thinking about ports at all. - Readable.
api.localhostsays what it is;:8081doesn't. - Same everything else.
expose,dependsOn,readyWhen, and the${ENVIRONMENT_*}variables all work exactly the same — a hostname target is just another target.
Shorthand. You can write the bare label and RepoGo fills in .localhost for you — "target": "web" is treated as web.localhost. Anything with a dot (web.example.com) or the literal localhost is left exactly as you wrote it.
What you provide: RepoGo forwards requests to
http://<your-hostname>inside the environment with the rightHostheader — it doesn't invent the routing. Your project (or a small local proxy) needs to actually answer on that hostname. Most "portless" dev tooling sets this up for you; if a hostname target returns "local server unreachable," that means nothing inside the box is serving that name yet. A plain port target ("3000") always works with no extra setup, so reach for portless when your stack already speaks hostnames.
Connecting services to each other#
When one service needs another's address — say your frontend calls your API — you don't hardcode it. RepoGo gives every service a set of environment variables describing the other services, which you can reference in cmd or env:
| Variable | Example value | Available when |
|---|---|---|
ENVIRONMENT_TARGET_<NAME> | 8080 | the other service declared a target |
ENVIRONMENT_URL_<NAME> | https://yourmac-8080-x7k2.repogo.dev | the other service is exposed (expose: true) |
ENVIRONMENT_DOMAIN_<NAME> | yourmac-8080-x7k2.repogo.dev | the other service is exposed (expose: true) |
<NAME> is the other service's name, uppercased with dashes turned into underscores — so a service named my-api becomes ENVIRONMENT_URL_MY_API.
Which one to use:
ENVIRONMENT_URL_<NAME>— the fullhttps://…address. Use for anything a browser hits (NEXT_PUBLIC_API_URL, OAuth callbacks, webhooks).ENVIRONMENT_DOMAIN_<NAME>— the host with no scheme. Use for CORS allowlists, cookie domains, anywhere a bare hostname is expected.ENVIRONMENT_TARGET_<NAME>— the local target (a port like8080, or a hostname likeweb.localhost). Use for server-to-server calls inside the environment:http://localhost:$ENVIRONMENT_TARGET_APIfor a port, orhttp://$ENVIRONMENT_TARGET_APIfor a hostname.
A private service (a target but no expose) gives you only ENVIRONMENT_TARGET_<NAME> — reachable on localhost inside the box, with no public URL. An exposed service (expose: true) gives you all three. So a web service reaches the api service like this:
{
"name": "web",
"cmd": "bun install && bun dev",
"env": { "NEXT_PUBLIC_API_URL": "${ENVIRONMENT_URL_API}" }
}Your service still chooses its own port or hostname the normal way (in your command or your app's config) — these variables are only for finding the other services.
Heads up: these values are read when a service starts. If you open or change a service's URL while everything is already running, restart the services that use it (right from their terminal tabs) so they pick up the new value.
Secrets, kept on your Mac#
Most services need secrets — API keys, database URLs, tokens. You should never commit those, and you may not want them sitting in the cloud. So RepoGo keeps your secrets on your Mac and lends them to a service only while it runs.
It works in two halves:
1. The repo says what it needs. In environment.json, a service lists named sources with envFrom — just names, nothing sensitive:
{ "name": "api", "cmd": "bun install && bun start", "envFrom": ["api-secrets"] }2. You say where it lives — once, in the app. In Env Sources, you point each name at a Mac and a file on it. On boot, RepoGo fetches that file from your Mac (your phone authorizes the read), loads the variables into the service in memory only, and never writes them to the environment's disk or a snapshot.
Because the repo only holds the name, a teammate who clones the same project just points that name at their own file on their own Mac — the same environment.json works for everyone.
Full walkthrough → Env Sources covers setting up a source, the per-read approval, using more than one Mac, and the security model.
Watching, stopping, and restarting#
Every service shows up as a terminal tab in your workspace:
- Watch it. Open the tab to see live output. If output piled up before you opened it, you'll see the recent history too.
- Stop it. Stop a single service without touching the others — handy when you want to free up a port or restart something cleanly.
- Restart it. Bring a stopped service back, or restart a running one to pick up a change. It runs the same command again in a fresh terminal.
When the whole environment sleeps and wakes, you don't have to do any of this — every service comes back on its own.
When services stop on their own#
On your Mac, RepoGo keeps your services running while you're using the workspace and stops them shortly after you leave — so a dev server you opened this morning isn't still holding port 3000 tonight.
It follows who's watching:
- While any tab or device has the workspace open, everything stays up. No timer, no countdown.
- When the last one closes, a 3-minute countdown starts. You'll see it on the workspace's tab, next to a Kill app button if you'd rather not wait it out.
- Come back before it runs out and the countdown is cancelled. Nothing stopped, nothing to restart.
- When it expires, RepoGo stops the services it started. Terminals you opened by hand are left alone — only the ones from
environment.jsonare managed.
Three minutes suits most dev servers. idleStop changes it per service:
{
"services": [
{ "name": "web", "cmd": "bun install && bun dev", "target": "3000", "idleStop": "now" },
{ "name": "db", "cmd": "docker compose up postgres", "target": "5432", "idleStop": "never" },
{ "name": "api", "cmd": "bun run start", "target": "8080" }
]
}idleStop | What happens when you stop viewing the workspace |
|---|---|
| (omitted) | Stopped when the 3-minute countdown expires. The right answer for almost everything. |
"now" | Stopped immediately, no grace period. Good for a heavy dev server, or anything holding a port you want back the moment you're done. |
"never" | Never stopped by the countdown. Good for a local database or a tunnel you want up until you say otherwise. |
Two things worth knowing:
- Kill app always wins.
"never"opts a service out of the idle countdown, not out of being stopped — the Kill app button stops everything for that workspace,neverservices included. - If nothing would be left to stop, no countdown appears. A workspace where every service is
"never"just shows no timer, rather than a clock that runs out and does nothing.
Cloud Sandboxes work differently, on purpose. A sandbox is scoped to one project and already stops everything when it sleeps, so its services stay up the whole time it's awake and
idleStopis ignored there. The idle countdown exists for your Mac, where every project you own shares one machine.
Viewing your app in the project browser#
With your dev server running, RepoGo can open it inside your workspace in a project browser tab. Most apps work with no changes. But if your app sends a header that blocks being embedded, the tab shows "refused to connect" — even though the same URL opens fine in a normal browser tab.
The culprit is almost always X-Frame-Options (or a Content-Security-Policy frame-ancestors rule). It tells browsers "don't let other pages embed me in a frame." That's the right call in production, but it also stops RepoGo's project browser from displaying your dev server.
The fix: don't send that header in development. A local dev server isn't a security boundary, and you keep the protection in production where it counts. In a Next.js next.config, make it production-only:
async headers() {
const isProd = process.env.NODE_ENV === "production";
return [
{
source: "/:path*",
headers: [
// …your other security headers…
...(isProd ? [{ key: "X-Frame-Options", value: "SAMEORIGIN" }] : []),
],
},
];
}Prefer to keep a header in dev? Use a Content-Security-Policy that allows RepoGo instead of blocking everything — frame-ancestors overrides X-Frame-Options and lets you name who may embed you:
Content-Security-Policy: frame-ancestors 'self' https://repogo.appEither way, restart your dev server afterward so it picks up the change — config files are read only at startup, so stop and start the service from its terminal tab.
Rule of thumb: if the project browser says "refused to connect" but the same URL loads fine in another browser, it's a framing header — not a RepoGo or networking problem. Relax it in dev and the page appears.
Working with multiple repositories#
An environment can hold several repositories, and each one can have its own environment.json. On wake, RepoGo starts the services from every repo, so an environment with three projects can bring up all three at once, each with its own set of terminals.
- A repo without an
environment.jsonsimply starts nothing — no setup required, no error. - Service names are scoped to their repo, so two projects can both have a
webservice without clashing. dependsOnonly orders services within the same repo. Services in different repos start independently.
Tips for a smooth setup#
- Install before you start. Chain it into
cmdwith&&—bun install && bun dev,npm install && npm run dev,pnpm install && pnpm dev. A fresh clone or cold boot may not have dependencies yet; install is a fast no-op when they're already there. - Start small. One
devservice is a great firstenvironment.json. Add more as you need them. - Name things clearly. The
nameis the terminal tab and how other services reference it, sowebandapibeatstart1andstart2. - Use
cwdfor monorepos. Point each service at the right subfolder instead ofcd-ing insidecmd. - Use
expose: trueonly for services that should be on the internet. Everything else stays private by default. - Reference other services with
${ENVIRONMENT_URL_<NAME>}(public) or${ENVIRONMENT_TARGET_<NAME>}(localhost) instead of hardcoding. - Keep secrets out of the repo. Use
envFrom: ["name"]and point the name at a file on your Mac in the app — never commit.envfiles. - Commit it.
environment.jsononly works once it's committed to the repo on your environment.
Frequently asked questions#
Where does the file go?
At the root of your repository, named exactly environment.json. Commit it like any other file.
Do I need to set anything up in the app?
No. Just add environment.json to your repo. RepoGo finds it and runs it automatically.
What if a repo doesn't have an environment.json?
Nothing starts for that repo, and that's fine. It's completely optional, per repo.
Can different branches run different services?
Yes. environment.json is part of your code, so each branch can have its own version. The environment runs whatever's on the branch it has checked out.
What happens if one of my commands fails to start? That service stays stopped — the others still come up. Open its terminal tab to see what went wrong, fix your command, and restart it. RepoGo doesn't try to guess or auto-recover a broken command.
Do I have to specify a target?
Only if other services need to reach this one, or you want it public. target declares where the service serves — a port ("3000") or a hostname ("web.localhost"); add expose: true to also open a public URL. With neither, the service just runs privately. RepoGo never opens a target you didn't ask for.
Can I use hostnames instead of ports?
Yes — set target to a hostname like web.localhost (or just web, and RepoGo adds .localhost). This is portless: apps claim names instead of ports, so you can run several in one sandbox with no collisions. Your stack needs to actually serve on that hostname; a plain port target always works with no setup.
How do secrets work without committing them?
List named sources with envFrom (e.g. ["api-secrets"]) and, in the app's Env Sources, point each name at a file on your Mac. On boot, RepoGo fetches that file from your Mac (your phone authorizes it), loads it into the service in memory, and never writes it to disk or a snapshot. The repo only contains the name. See Env Sources for the full flow, including teams and multiple Macs.
Will my services keep running while the environment is asleep?
No — a sleeping environment isn't running anything. But everything in your environment.json comes right back up the moment it wakes.
Why did my dev server stop after I closed the tab?
On a Mac, RepoGo stops managed services 3 minutes after the last person stops viewing the workspace. Reopen it and they start again. To keep one running regardless, set "idleStop": "never"; to stop it the instant you leave, set "idleStop": "now". See When services stop on their own.
Can I still open extra terminals by hand?
Yes. Your environment.json services appear as terminals automatically, and you can open as many additional terminals as you like alongside them.
The project browser says "refused to connect" — what's wrong?
Your app is sending a security header (X-Frame-Options, or a Content-Security-Policy frame-ancestors rule) that blocks it from being embedded in RepoGo's project browser. It's meant for production; send it only there. See Viewing your app in the project browser.
Next steps#
- New to cloud machines? Start with Cloud Sandboxes on Vercel.
- New to RepoGo? See Getting Started.