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:

json
{
  "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 cmd with && (e.g. bun install && bun dev). A freshly cloned repo — or a cold boot before dependencies are cached — won't have node_modules yet, so install first. It's a fast no-op once deps are already there.

How it works#

  1. You commit environment.json to your repo. It lives with your code, so it's the same on every machine and every clone, and it can differ per branch.
  2. 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.
  3. 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.
  4. 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:

json
{
  "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#

FieldRequiredWhat it does
nameYesA 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.
typeNo"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.
cmdYesThe 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.
cwdNoThe 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.
targetNoWhere 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.)
exposeNotrue 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.
readyWhenNoA 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.
envNoExtra environment variables for this service, as name/value pairs. You can reference another service's address here (see Connecting services).
envFromNoA 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).
dependsOnNoOther 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.
autoStartNoDefaults to true. Set false to define a service without starting it automatically — it shows as a stopped tab you can start by hand.
autoRestartNoIf 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.
idleStopNoWhat 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.
versionNoThe 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:

json
{
  "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:

json
{
  "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:

json
{
  "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}" }
    }
  ]
}
  • cwd is relative to the repo root — one install per app, in its own folder.
  • dependsOn waits for spawn; readyWhen waits for serving; a setup is always waited on until it exits.
  • ${ENVIRONMENT_URL_API} is api's public URL (it's exposed). Don't want it public? Drop expose and 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:

json
{ "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.

  • target alone → 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 to false, 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 set expose: true (or open one yourself in the app).
  • Comes back on every wake. Because expose: true is 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.

json
{
  "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 3000 can'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.localhost says what it is; :8081 doesn'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 right Host header — 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:

VariableExample valueAvailable when
ENVIRONMENT_TARGET_<NAME>8080the other service declared a target
ENVIRONMENT_URL_<NAME>https://yourmac-8080-x7k2.repogo.devthe other service is exposed (expose: true)
ENVIRONMENT_DOMAIN_<NAME>yourmac-8080-x7k2.repogo.devthe 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 full https://… 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 like 8080, or a hostname like web.localhost). Use for server-to-server calls inside the environment: http://localhost:$ENVIRONMENT_TARGET_API for a port, or http://$ENVIRONMENT_TARGET_API for 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:

json
{
  "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:

json
{ "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:

  1. While any tab or device has the workspace open, everything stays up. No timer, no countdown.
  2. 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.
  3. Come back before it runs out and the countdown is cancelled. Nothing stopped, nothing to restart.
  4. When it expires, RepoGo stops the services it started. Terminals you opened by hand are left alone — only the ones from environment.json are managed.

Three minutes suits most dev servers. idleStop changes it per service:

json
{
  "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" }
  ]
}
idleStopWhat 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, never services 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 idleStop is 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:

typescript
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:

plain text
Content-Security-Policy: frame-ancestors 'self' https://repogo.app

Either 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.json simply starts nothing — no setup required, no error.
  • Service names are scoped to their repo, so two projects can both have a web service without clashing.
  • dependsOn only orders services within the same repo. Services in different repos start independently.

Tips for a smooth setup#

  • Install before you start. Chain it into cmd with &&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 dev service is a great first environment.json. Add more as you need them.
  • Name things clearly. The name is the terminal tab and how other services reference it, so web and api beat start1 and start2.
  • Use cwd for monorepos. Point each service at the right subfolder instead of cd-ing inside cmd.
  • Use expose: true only 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 .env files.
  • Commit it. environment.json only 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#