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.
The launch config
Section titled “The launch config”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.
{ "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):
| Field | Required | Meaning |
|---|---|---|
name | yes | Target name, unique within the app. The value you pass to --target. |
command | yes | The start command, a single string. Run through a shell, so pipes and && work; use whatever the project actually uses. |
cwd | no | Working directory, relative to the app dir (projects/<app>/). Defaults to the app dir itself. |
port | no | The port the server listens on. Drives the readiness check and the printed URL (see below). Must be a port already exposed (see below). |
env | no | Extra environment variables for the process. |
readyTimeout | no | Seconds 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). |
default | no | Marks 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.
Two ways to start it
Section titled “Two ways to start it”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-ctldirectly (it has no hostmonoceros):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.
Readiness and the reachable URL
Section titled “Readiness and the reachable URL”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.
Give a compiled server more time
Section titled “Give a compiled server more time”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.logThe 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.
Where the state lives
Section titled “Where the state lives”Logs and runtime state are kept apart (see File layout):
- Logs:
logs/<app>/<target>.log- whatmonoceros logstails. - Process state:
.monoceros/run/<app>/<target>.pid- the process-group id, used bystopto signal the whole group (so children likenodeundernpmorjavaunder 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.
Surviving a rebuild
Section titled “Surviving 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:
monoceros status acme # the whole stack, apps includedmonoceros status acme web # just the "web" app's targetsVersioning the launch config
Section titled “Versioning the launch config”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:
.monoceros/*!.monoceros/launch.jsonThe .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.
See also
Section titled “See also”monoceros start/monoceros stop- start or stop an app from the host.monoceros status- see which apps are running, alongside the container and services.monoceros logs- tail an app’s log.monoceros list-apps- see which apps and targets exist.