A service is a named, long-running process inside a box. Where 4kr exec --bg gives you a raw detached process keyed only by its PID, a service wraps that same detached-exec mechanism in a managed lifecycle: a stable name, a state file, status and log inspection, restart and stop, an optional public URL, and blue/green deploys with a preview instance. Use a service for anything that should keep running after the command that started it returns: a web server, an API, a background worker. For one-off or short scripts, plain 4kr exec is enough.
The CLI uses “fork” in some argument help text, while the API and routes use “box”. They refer to the same thing. This page uses “box”.

Mental model

  • A service is identified by its box and a service name, both of which must be valid names (lowercase a-z0-9-).
  • You manage single services with 4kr service subcommands.
  • A service runs detached on a port inside the box’s pod. It does not get its own DNS name — you reach it through the box.
  • Publishing a service attaches an ingress host so the port is reachable at a public URL. Publishing and unpublishing only touch ingress and state; the process keeps running.
  • A blue/green deploy starts a second instance (the preview) on a new port behind a preview host, then promotes it to active.
  • Service state lives in the box at /var/lib/forks/services/<service>.json. Service logs are written to /tmp/boxes/exec/<pid>.log.
service status and service inspect report a service as running (the active process is alive) or stopped (the state file exists but no live active process); a service with no state file returns 404 service_not_found. The separate unknown state appears only in service list rows, for a state file that exists but cannot be parsed.
The reproducible command hints returned by service status --json and service inspect are generated by the API and begin with the literal word box (for example box service start ...). The actual CLI binary is 4kr. Substitute 4kr for box when copying these hints.

Service vs exec --bg

4kr exec my-box --bg -- python worker.py
4kr exec --bg runs a command detached and returns its PID. That is all you get: there is no name, status, log tailing, restart, publish, or preview/promote lifecycle — only a PID. 4kr service start uses the same detached mechanism but adds a named state file, status and log inspection, restart and stop, optional publishing, and blue/green deploys. Reach for a service when you need any of that.

Start a service

4kr service start my-box web --port 8080 -- node server.js
The positional arguments are the box and the service name. The command to run goes after --. Flags:
  • --port <PORT>required. The port the process listens on inside the box.
  • --publish <SUBDOMAIN> — optional. Publish the service at this subdomain using the default domain.
  • --domain <DOMAIN> — optional. Override the publish domain.
  • -t, --tag <K=V> — repeatable. Attach tags to the service.
  • -p, --project <P>
4kr service start my-box web --port 8080 --publish app -t box.ui=main -- node server.js
The -t box.ui=main tag is suggested in the CLI help as a marker for the primary web UI. It is a labeling convention only — no behavior is attached to it.
The command is required: starting a service with no command after -- fails with Error: command is required and exits 1. The API also rejects an empty command, and validates both the box name and service name — an invalid name returns invalid_name. The server starts the process detached (it survives hangups), records the PID, optionally publishes an ingress host, and writes the service state file.

Inspect services

Status

4kr service status my-box web
Prints the state, tags (sorted, comma-joined), PID, the active URL, and the preview URL. Pass -j, --json for the raw payload.

Inspect

4kr service inspect my-box web
inspect adds runtime and spec detail on top of status: the active instance’s PID, liveness, port, and command (and the same for any preview), followed by reproducible command hints for start, deploy preview, promote, publish, and unpublish. Pass -j, --json for the raw payload.

List

4kr service list my-box
Lists every service in the box as a table with columns NAME, STATE, PID, PORT, TAGS, URL. The URL column shows the active URL, falling back to the preview URL. Pass -j, --json for raw output.

Logs

4kr service logs my-box web --lines 200
Tails the active process’s log file. -l, --lines defaults to 100. If the service has no active process, the command fails with service_no_active_process.
service logs has no --follow and no --json. It returns a single tail of the log.

Restart and stop

Restart

4kr service restart my-box web
Kills the active PID and re-runs the same command detached, updating the state file. Restart operates only on the active process: if the service has only a preview instance and no active one, it fails with service_no_active_process.

Stop

4kr service stop my-box web
Stop kills both the active and preview processes, unpublishes both the active and preview hosts, and clears the active, preview, preview-subdomain, and publish state. After a stop the service no longer has a published URL.

Publish and unpublish

Publishing attaches an ingress host so the service’s port is reachable at a public URL. The process keeps running throughout — publishing only changes ingress and state.
4kr service publish my-box web --subdomain app
Flags:
  • --subdomain <SUBDOMAIN>required. Use @ for the apex domain.
  • --domain <DOMAIN> — optional override.
  • --port <PORT> — optional. Override the published port. The default is the service’s active port.
  • -j, --json
The published port resolves in order: the --port value, then the active port, then the preview port. If none is available, the call fails with service_port_required. If a preview exists, its host is republished too. On success the response includes the active URL, for example {"ok":true,"active_url":"https://app.example.com"}. URLs are built as https://<subdomain>.<domain>, except @, which maps to https://<domain> (the apex). The publish domain comes from the configured publish domain unless you pass --domain; an empty domain fails with invalid_publish_config.
4kr service unpublish my-box web
Unpublish removes the ingress for the active host and any preview host and clears the publish state. The process keeps running.

Blue/green deploys

A deploy starts a preview instance of the service on a new port behind a preview host, leaving the current active instance serving traffic. Once you have verified the preview, you promote it to active.
The service must be published before you can deploy a preview. If the service has no publish state, deploy fails with service_not_published. Publish first, then deploy.
1

Publish the service

4kr service publish my-box web --subdomain app
2

Deploy a preview

4kr service deploy my-box web --preview --port 8081 -- node server.js
--preview is required; a bare --preview uses the default preview subdomain, and --preview <SUBDOMAIN> overrides it. --port is required, and the command goes after --.If you omit the command, the CLI fails with Error: command is required; if you omit --preview, it fails with Error: --preview is required. The default preview subdomain is <subdomain>-preview, or apex-preview when the published subdomain is @. The preview is published on the new port and stored as the preview instance.
3

Promote the preview

4kr service promote my-box web
Promote republishes the active host to the preview’s port, unpublishes the preview host, kills the old active process, and makes the preview the new active instance (clearing the preview slot). It fails with service_not_published if the service is not published, or service_no_preview if there is no preview to promote.

Multi-service stacks with compose

4kr compose brings up several services, plus managed dependencies like Postgres or Redis, from a single YAML file. It is the right tool when a box runs more than one service together, or when services need a database or cache alongside them.
4kr compose up
Subcommands: up, down, ps, logs.

The compose file

The default file is fork.compose.yaml. Override it with -f, --file.
name: shop
base: box-base
addons:
  - use: postgres
    as: db
services:
  api:
    port: 8080
    command: ["node", "server.js"]
    publish: app
    env:
      DATABASE_URL: ${addons.db.url}
  worker:
    port: 9000
    command: ["node", "worker.js"]
Top-level keys (ComposeSpec):
  • namerequired, non-empty. The stack name.
  • project — optional.
  • base — optional.
  • addons — optional list, default empty.
  • servicesrequired, non-empty map.
Each addon (ComposeAddon) needs a non-empty use (the addon type) and as (its alias), plus optional fork and hostname. Each service (ComposeService) needs a required port and a required non-empty command. Optional keys are fork, publish, domain, and env (a map, default empty). Validation rejects an empty name (compose.name is required), empty services (compose.services is required), any addon missing use or as, and any service with an empty name or empty command.

Addon substitution

You can reference an addon’s outputs in a service’s environment with ${addons.<alias>.<key>}:
services:
  api:
    port: 8080
    command: ["node", "server.js"]
    env:
      DATABASE_URL: ${addons.db.url}
      PGHOST: ${addons.db.host}
Substitution applies only to services.<name>.env values. It is not applied to command, publish, domain, or any other field. Text before and after the token is preserved, so concatenation works.
Substitution errors include unterminated ${...} expression, unknown addon alias '<alias>', and unknown addon output '<alias>.<key>'. Interpolated environment is injected by wrapping the command so the variables are exported before it runs.

Addon types and outputs

Each addon type shells out to its managed helper to create the dependency, then exposes outputs you can substitute.
Use postgres, postgresql, or pg. Outputs:
KeyValue
host<fork>.<project>
port5432
userpostgres
passwordpostgres
databasepostgres
urlpostgresql://postgres:postgres@<host>:5432/postgres
Any other use value fails with unsupported addon type '<other>'. An addon’s default fork name is <stack>-<as> unless you set fork on the addon.

What up does

4kr compose up accepts -f, --file, -p, --project, and --force-recreate. It runs in order:
1

Ensure the anchor fork

Creates a fork named after the stack, tolerating “already exists”.
2

Ensure addons

Shells out to the helper binaries to create each addon dependency.
3

Ensure service boxes

For each unique service fork (default: the stack name), creates the box if it is missing.
4

Start each service

If a service is already running and you did not pass --force-recreate, it prints skip <name> (already running). Otherwise it starts the service with the interpolated command and prints started <name>. Compose-started services are started with no tags.
5

Save stack state

Writes the stack state and prints state saved <project>:<stack>.
up is idempotent: “already exists” and 409 responses are treated as success. --force-recreate re-runs addon creation and service starts, bypassing the running-service skip.

ps, logs, and down

4kr compose ps
4kr compose logs api
4kr compose down
  • ps prints <fork>/<name> <state> per service, where state is running, stopped, or missing (a 404 for the service maps to missing). Pass -j, --json for {stack, project, services:[{name,fork,port,state}]}.
  • logs prints a ==> <name> <== header before each service’s logs. Pass an optional service name to filter to one service. -l, --lines defaults to 100. There is no --json.
  • down stops each service (best-effort, warning on failures), stops each addon through its helper, deletes the in-box stack state, and prints stopped <name> and state deleted <project>:<stack>.
down does not delete any box or fork. It only stops services and addons and removes the compose metadata. To remove the boxes themselves, delete them explicitly.
Stack state is stored in the anchor box at /var/lib/boxes/compose/state.json. ps, logs, and down resolve their target in order: an explicit --file, then --stack, then the default fork.compose.yaml if present, otherwise the error compose file not found; pass --file or use --stack.

Managed dependency helpers

Compose addons shell out to standalone helper binaries — forkr-pg, forkr-redis, and forkr-matrix — which you can also run directly to manage a database, cache, or Matrix homeserver in a box. Each takes connection flags (--url, --token, --ip, --insecure, --secure) and resolves the project from --project, then $FORKR_PROJECT, then the git-derived name, then default.

forkr-pg

A Postgres helper. Subcommands: new, fork, connect, start, status, stop, build-base, docs, snapshot, list, delete. Defaults: user postgres, database postgres, password postgres, port 5432. Data lives in a volume named pgdata-<fork> mounted at /var/lib/postgresql/data.
forkr-pg new orders-db
new <name> creates the data volume and fork, initializes Postgres, waits for the port (--wait-timeout defaults to 60 seconds), and prints ready to connect with a psql command. connect <name> runs a local psql if one is available and the host resolves, otherwise falls back to an in-box console psql. fork <src> <dst> snapshots the source’s data and creates the new fork from that snapshot; the source keeps running.

forkr-redis

A Redis helper. Subcommands: new, fork, connect, status, stop, build-base, docs, snapshot, list, delete.
Unlike forkr-pg, forkr-redis has no start subcommand.
Defaults: port 6379, data at /var/lib/redis. AOF persistence is on. Stop performs a shutdown save.
forkr-redis new cache
connect <name> uses redis-cli against the host on port 6379. --password is optional, with no default.

forkr-matrix

A Matrix (Synapse) helper with a bundled Postgres. Subcommands: new, fork, publish, new-user, get-access-token, connect-db, status, stop, build-base, docs, snapshot, list, delete. Defaults: Synapse on port 8080, bundled Postgres on 5432, data under /var/lib/matrix.
forkr-matrix new chat --hostname chat.example.com
new <name> waits for both Postgres (5432) and Synapse (8080). With --hostname it publishes that host and sets Synapse’s server_name and public_baseurl to it; without one, the server is unpublished and reachable only internally. new-user <name> <username> --password <p> [--admin] creates a user (--password is required). get-access-token <name> --user <u> --password <p> returns a token, with --hostname and --url mutually exclusive. publish <name> [<subdomain>] updates Synapse’s public_baseurl; the subdomain is required unless you pass --hostname.

Connecting services

Services do not get their own DNS names. A service listens on a port on its box’s pod, so you reach it through the box’s name and port. Managed dependencies and compose addons form their internal host as <fork>.<project>. A Postgres box orders-db in project shop is reachable at orders-db.shop:5432; a Redis box cache in the same project is cache.shop:6379. Use these <fork>.<project> hosts (as exposed in addon outputs like ${addons.db.host}) to wire services to their dependencies.

Boxes

The Kubernetes-backed sandbox a service runs inside.

Environment

The runtime, filesystem, and DNS your services see inside a box.

Data volumes

Persistent storage that outlives a box — where database state lives.

API reference

The HTTP routes behind 4kr service and 4kr compose.