Skip to content

Long-running app servers

A dev server (a web app, an API) has to keep running after the session that started it ends. A plain npm start in a shell dies the moment you close the terminal or the AI agent exits, and then acme.localhost returns a 502. The server must run detached, and you need a way to start and stop it again without remembering the exact command.

Monoceros solves this with a small, declarative contract:

  • a per-app launch config describes how to start the server,
  • the build agent starts it from inside the container,
  • and you start, stop or restart it from the host without knowing the command.

Monoceros never guesses the start command. It is not always npm run dev - it might be mvn spring-boot:run, python manage.py runserver, or cargo run. Whoever knows the command (the agent at build time, or you) writes it down once.

Each app declares its servers in its own repo, at projects/<app>/.monoceros/launch.json. The file travels with the app, is hand-editable, and is maintained by the build agent: when it starts a long-running server, it adds or updates the matching entry.

projects/web/.monoceros/launch.json
{
"targets": [
{
"name": "api",
"command": "npm run dev:api",
"port": 3001,
"default": true
},
{
"name": "web",
"command": "npm run dev:web",
"port": 5173,
"env": { "API_URL": "http://localhost:3001" },
"default": true
}
]
}

A targets list, each a named target (the key configurations is also accepted, for compatibility):

FieldRequiredMeaning
nameyesTarget name, unique within the app. The value you pass to --target.
commandyesThe start command, a single string. Run through a shell, so pipes and && work; use whatever the project actually uses.
cwdnoWorking directory, relative to the app dir (projects/<app>/). Defaults to the app dir itself.
portnoThe port the server listens on. Drives the readiness check and the printed URL (see below). Must be a port already exposed (see below).
envnoExtra environment variables for the process.
readyTimeoutnoSeconds to wait for port to listen before the target counts as failed. Defaults to 20. Raise it when the command builds before it serves (see below).
defaultnoMarks a target as part of the start set used when --target is omitted. Any number per app.

When --target is omitted, Monoceros starts the default set: every target marked default (or the sole target if there is just one). So an app whose API and web frontend belong together marks both, and one start brings them both up. With no default and more than one target, it asks you to pick with --target.

The default set starts in the order the entries appear in the file, and because start waits for each target’s port before moving on (see below), ordering an entry before the things that depend on it gives real sequencing - in the example above the api is listening before web starts. If a target in the set fails to come up, the rest are not started.

The start/stop mechanics live in one place - an in-container runner called monoceros-ctl - reached two ways:

  • The build agent, inside the container, calls monoceros-ctl directly (it has no host monoceros):

    Terminal window
    monoceros-ctl start web
  • You, on the host, use the normal lifecycle commands with an app argument, which bring the container up first if needed and then run the same runner:

    Terminal window
    monoceros start acme web

Both do exactly the same thing. See monoceros start, monoceros stop, monoceros logs and monoceros list-apps.

When a target declares a port, start does not return the moment the process is spawned - it waits until something actually listens on that port. So “started” means “up”, not “spawned and maybe crashed a second later”. On success it prints the reachable URL, e.g. http://acme-5173.localhost.

The server must listen on 0.0.0.0 (not 127.0.0.1) on the exposed port, or the Monoceros proxy cannot reach it.

The default window is 20 seconds, which is plenty for an interpreted stack that starts serving right away. It is not plenty when the start command builds first: a Go, Maven, Gradle or Rust build with a cold cache easily runs longer, and the target is then reported failed while it is still perfectly fine. Because the default set is fail-fast, everything after it stays down too.

Tell the target how long it really needs:

{ "name": "api", "command": "./dev.sh", "port": 7777, "readyTimeout": 120, "default": true }

If the window does run out while the process is still alive, start says so rather than implying a crash, and names the field to raise:

✗ api port 7777 still not listening after 20s, process alive (raise readyTimeout) - see logs/api/api.log

The field needs runtime 1.6.2 or newer; an older runtime ignores it and keeps the 20 seconds. Run monoceros upgrade to move a workbench up.

The port only describes a port that is already exposed on the container - it never adds one. If you need a new port, add it on the host with monoceros add-port and re-apply; the launch config cannot open ports from inside the container.

Logs and runtime state are kept apart (see File layout):

  • Logs: logs/<app>/<target>.log - what monoceros logs tails.
  • Process state: .monoceros/run/<app>/<target>.pid - the process-group id, used by stop to signal the whole group (so children like node under npm or java under Maven stop too). You never touch this.

The presence of that pid file also marks a server as “wanted”: it is written when you start it and removed only when you explicitly stop it. That is what lets your servers come back across a rebuild.

monoceros apply recreates the container, so every server running inside it is torn down with it. Afterwards Monoceros restarts the ones that were running - the set you started and did not stop - the same unless-stopped behaviour the workspace and its services already have. A server you stopped on purpose stays down. Check what is up at any time with monoceros status:

Terminal window
monoceros status acme # the whole stack, apps included
monoceros status acme web # just the "web" app's targets

By default the .monoceros/ directory is ignored in every repo inside the container, so the launch config does not clutter your app’s git status. To track it (recommended, so it travels with the repo), opt in from the app’s own .gitignore:

projects/web/.gitignore
.monoceros/*
!.monoceros/launch.json

The .monoceros/* + !… form is needed because a single-file re-include does not work while the whole directory is excluded (git does not descend into an excluded directory). Once the file is tracked, the ignore rule no longer applies to it.