.fghj.yaml
Every repo that participates in fghj carries its own .fghj.yaml at its
root. There’s no shared/root config — each file is self-contained and
validated independently against fghj’s CUE schema; see
fghj validate. The full grammar lives in schema/component.cue
and schema/dependency.cue in the fghj repo.
Top-level shape
Section titled “Top-level shape”version: "1.0"services: <service-name>: # ... see Services belowflows: <flow-name>: # ... see Flows belowservices
Section titled “services”Keyed by service name — a repo can declare more than one independently
buildable service, each with its own Dockerfile and dependencies (e.g. a
dev-server process and a backend API process built from the same repo). The
map key is the service’s name (lowercase, [a-z0-9][a-z0-9-]*) — it’s a
human-readable label, not the node’s internal id; see
Node identity & domains for why
those differ. Most repos declare exactly one.
services: cart-service: build: context: . dockerfile: Dockerfile args: NODE_ENV: production ports: "8080": primary: true "9090": name: admin domain_scope: run environment: - PORT=8080 env_file: - .env platform: linux/arm64 command: ["npm", "run", "dev"] restart: unless-stopped user: "1000:1000" working_dir: /app labels: team: platform cap_add: ["NET_ADMIN"] extra_hosts: - "metadata:169.254.169.254" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 10 retries: 3 dependencies: - kind: service repo: git@github.com:acme/auth-service.git default_branch: main| Field | Type | Description |
|---|---|---|
build.context | string | Docker build context. Defaults to .. |
build.dockerfile | string | Dockerfile path, relative to context. Defaults to Dockerfile. |
build.args | map of string→string | Build-time --build-arg values. |
ports | map of container-port→#Port | Declared container ports. The map key is the literal container port number (e.g. "8080"), published to Docker as-is — not a semantic label. See Ports below. |
domain_scope | "run" | "stable" | Whether this service’s derived domain includes the run id. Defaults to "run". See Node identity & domains. |
environment | map or list | Either {KEY: value} or a list of "KEY=value" strings — mirrors Docker Compose’s own environment shape. Values can reference a sibling’s domain with ${FGHJ_SERVICE_FQDN}/${FGHJ_SERVICE_FQDN_HTTP} — see Domain templates in environment below. |
env_file | list of strings | .env-style files loaded before environment — Compose’s env_file. Each path resolves against this repo’s own checkout root, same rule as #Volume.host. An explicit environment entry always wins over one loaded from a file. Also available on kind: backing — see its own field table below for how the path resolves there. Same ${FGHJ_SERVICE_FQDN} templating as environment applies to loaded values too. |
platform | string, optional | Pins the platform (os[/arch[/variant]], e.g. linux/arm64) passed to docker build --platform, for cross-compiling this service’s image to a specific architecture. Unset (the default) builds for the host’s own platform. |
command | list of strings | Overrides the image’s default CMD, Compose-command-style. Empty (the default) leaves the image’s own CMD/ENTRYPOINT untouched. |
restart | "no" | "always" | "on-failure" | "unless-stopped" | Compose-equivalent restart policy. Defaults to "no" — a stopped container stays stopped; fghj daemon’s own ensure_running is the usual way a container comes back, not Docker’s own restart machinery. |
user | string, optional | Overrides the image’s default container user, e.g. "1000:1000" or "postgres". |
working_dir | string, optional | Overrides the image’s default working directory. |
labels | map of string→string | Extra container labels, merged under fghj’s own com.docker.compose.* labels — fghj’s own always win on a key conflict. |
cap_add / cap_drop | list of strings | Linux capabilities to add/drop — Compose’s cap_add/cap_drop. |
privileged | bool | Runs the container with extended, near-host-equivalent privileges. Defaults to false — only set this for a real, specific need. |
extra_hosts | list of "hostname:ip" strings | Extra literal entries written into this container’s own /etc/hosts — Compose’s extra_hosts. Distinct from additional_hosts below: this is the container resolving something else, not the host resolving this container. |
healthcheck | #Healthcheck, optional | A Docker HEALTHCHECK. See Healthcheck & start order below. |
volumes | list of #Volume | Bind mounts and named volumes. See Volumes below. |
additional_hosts | list of #HostAlias | Extra literal hostname aliases this service also answers on, alongside its derived domain — each one optionally wildcarded to also match every subdomain of it. See Additional hosts below. |
dependencies | list of #Dependency | This service’s baseline dependencies — always pulled in regardless of which flow is selected. See Dependencies below. |
The map key is the actual container port — the same number your app listens
on inside the container — not a semantic label. Give it a name if you want
a label too.
ports: "8080": primary: true "9090": name: admin "9000": host_port: 9000| Field | Type | Description |
|---|---|---|
primary | bool | At most one port per service should set this. Puts the port at the service’s own domain (cart.myworkspace.fghj.internal). Defaults to false. |
name | string, optional | Gives the port an additional nested domain: {name}.{service's domain}. Can be combined with primary. |
host_port | 1–65535, optional | Pin the host-side published port instead of letting Docker assign a random ephemeral one — for protocols whose clients hardcode a port and can’t go through name-based routing at all. Only one run can hold this exact host port at a time. |
wildcard | bool | When primary and/or name is set, also match every subdomain of this port’s derived domain, not just the exact name — e.g. a primary port gets *.cart.myworkspace.fghj.internal too, not just cart.myworkspace.fghj.internal itself. No effect otherwise (a warning, not a hard failure, if set on a port that’s neither). Defaults to false. |
A port with neither primary nor name is still published to an
ephemeral localhost port, just with no *.fghj.internal name.
Domain templates in environment
Section titled “Domain templates in environment”environment (and env_file) values can reference a sibling’s domain
without hand-computing it. Every node actually has two derived domains —
see Node identity &
domains
and Split DNS — and the macro form you use picks
which one you get:
environment: DATABASE_URL: postgres://user:pass@${FGHJ_SERVICE_FQDN:postgres}:5432/app PMA_ABSOLUTE_URI: https://${FGHJ_SERVICE_FQDN_HTTP}/| Token | Resolves to |
|---|---|
${FGHJ_SERVICE_FQDN} | This node’s own raw domain (*.fghj.raw.internal) — direct, in-network-only, resolved straight to the real container IP. This is the default because it’s what nearly every real caller needs: a database connection string, an internal API call, a raw port that isn’t HTTP(S) at all. |
${FGHJ_SERVICE_FQDN:name} | The raw domain of whichever sibling is declared with that leaf name, same lookup rules as below. |
${FGHJ_SERVICE_FQDN_HTTP} / ${FGHJ_SERVICE_FQDN_HTTP:name} | The same lookups, but resolving to the http domain (*.fghj.internal) instead — proxied, TLS-terminated, the same address in or out of the run’s docker network. Use this only when something specifically needs that proxied identity on purpose, e.g. minting a presigned URL meant to be handed to something outside the network. Reachable automatically from inside a container too — see Reaching the TLS proxy from inside a container below. |
${FGHJ_SERVICE_FQDN:a::b::name} / ${FGHJ_SERVICE_FQDN_HTTP:a::b::name} | Same lookup, but disambiguates a leaf name that matches more than one sibling by also qualifying it with as many of its owning segments as needed, root-first — the same segments a node’s id is built from leaf-first ({name}.{owner-id}), just written in the opposite, more-readable order. E.g. aikifactory::aikifactory::minio reaches the same node as the id minio.aikifactory.aikifactory (before the workspace suffix). |
The bare name lookup (with or without the _HTTP suffix) resolves to
whichever sibling is declared with that leaf name: a kind: backing
dependency owned by the same service as the node whose environment this
is (a backing dependency can reference another backing dependency this way
too, not just the owning service), or — if no such backing dependency
matches — a service this node directly depends on via kind: service
(same-repo or cross-repo).
Can’t reach a named port on a sibling — only its bare domain — and for
the kind: service case, only a dependency this exact node declares
itself, not a transitively-reached one. A malformed token (missing }) or
a path that doesn’t resolve to any sibling is left in the output as literal
text rather than failing the run, so a typo is diagnosable from the
container’s own env instead of silently swallowed.
Volumes
Section titled “Volumes”Each entry is either a bind mount or a named volume, distinguished
by which key you set — host for a bind mount, name for a named volume.
Setting both, or neither, fails fghj validate.
Setting a bind mount
Section titled “Setting a bind mount”Use host when you want a path on your machine mounted straight into the
container — the everyday case is mounting your own live source over what
the image built, so edits on disk show up without a rebuild:
volumes: - host: ./src container: /app/srchost resolves relative to this repo’s own checkout root (not
build.context). It is not sandboxed to that repo, though — an
absolute path, or a ..-escaping relative one, passes straight through to
Docker exactly as written, same as Compose. That’s what lets you reach a
sibling repo checked out next to this one:
volumes: - host: ../intel container: /app/intel read_only: true| Field | Type | Description |
|---|---|---|
host | string | A host path. Relative paths resolve against this repo’s checkout root; absolute or ..-escaping paths pass through unchanged. |
container | string | Mount path inside the container. |
read_only | bool | Mounts read-only. Defaults to false. |
Setting a named volume
Section titled “Setting a named volume”Use name instead of host when you want Docker-managed storage that
isn’t tied to any host path — the everyday case is a kind: backing
Postgres whose data needs to survive a container restart:
volumes: - name: pgdata container: /var/lib/postgresql/dataname is a bare label, like #Port.name — the real Docker volume name is
derived from it (folding in the workspace and, depending on scope, the
run), never the literal string you write. That derivation is also the
entire sharing mechanism: any other node — a different service, a
different backing dependency, related or not — that declares the same
name and the same scope resolves to that same derived name and
therefore shares the same underlying storage, with no ownership
relationship required:
# service A's .fghj.yamlvolumes: - name: shared-cache container: /app/.cache
# service B's .fghj.yaml — same name, same scope, same volumevolumes: - name: shared-cache container: /var/cache/app| Field | Type | Description |
|---|---|---|
name | string | A bare label. Two nodes with the same name + scope share one Docker volume. |
scope | "run" | "stable" | Same semantics as domain_scope: "run" (the default) gives each run (including preview/named runs) its own fresh empty volume; "stable" gives the volume one fixed identity that persists across every run. |
container | string | Mount path inside the container. |
read_only | bool | Mounts read-only. Defaults to false. |
Stopping a "run"-scoped volume’s named/preview run deletes that volume
along with its containers and network — since scope: "run" under a named
run derives a run-specific volume name to begin with (folding the run id
in), there’s nothing else that could still be using it once the run
stops. The default run and any "stable"-scoped volume are never deleted
this way: a "stable" volume’s entire point is to persist across every
run, and the default run’s own "run"-scoped volumes get the exact same
derived name on every start, so stopping and restarting the default run
must leave their data in place.
Additional hosts
Section titled “Additional hosts”A service is normally only reachable at its derived *.fghj.internal
domain (see Node identity & domains).
additional_hosts lets it also answer on one or more extra, literal
hostnames — useful when something outside fghj already has a hostname on
file, like a third-party OAuth callback pointing at aikido.local, and
reconfiguring that third party just to fit fghj’s own domain isn’t
practical. Each entry is a bare hostname (exact match only) or an object
with an explicit wildcard toggle:
services: aikido-core: ports: "3000": primary: true additional_hosts: - aikido.local - app.local.aikido.io - host: myservice.local wildcard: trueRequires the service to have a primary port — declaring additional_hosts
without one is a warning (those hosts wouldn’t be routed to anything), not
a hard fghj validate failure. Each alias is routed to the same port the
service’s own derived domain uses.
Whether an alias gets HTTPS depends on its TLD:
- Under an IANA reserved special-use TLD —
.local,.test,.internal, or.localhost(RFC 2606 / 6762, never delegated on the real internet) — it’s issued a certificate from fghj’s local CA, same as any*.fghj.internalname, but only while some running container actually claims it (aikido.localabove). - Anything else is treated as a real, potentially internet-routable
hostname and is proxied over plain HTTP only — fghj’s CA is a
system-trusted root, and minting a certificate for a domain it doesn’t
own would be trusted by every app on the machine, not just fghj’s own
proxy (
app.local.aikido.ioabove).
An alias can never sit inside fghj.internal itself — that domain is
always derived, never author-declared, the same rule ports’ name
field follows.
Wildcarding an alias
Section titled “Wildcarding an alias”A bare entry (or wildcard: false, the default) only ever matches the
exact name you list — it can’t help with a service that does
tenant-per-subdomain routing in production (acme.myservice.org,
microsoft.myservice.org, …, arbitrarily many, not enumerable up front).
wildcard: true claims a whole DNS subtree instead: the entry matches its
own apex and any subdomain of it, however deep, including ones you never
listed:
services: myservice: ports: "8080": primary: true additional_hosts: - host: myservice.local wildcard: trueWith this, acme.myservice.local, microsoft.myservice.local, and any
other *.myservice.local all route to myservice’s primary port — the
proxy forwards the raw Host header/SNI untouched, so the app’s own
tenant-resolution logic runs exactly as it does in production.
The reserved-TLD rule for HTTPS still applies per-name (a real cert is
minted lazily for each exact subdomain actually requested, never a literal
X.509 wildcard cert). fghj validate also warns if two different nodes
declare the same wildcarded suffix — unlike a plain exact-match collision,
this claims traffic for a whole subtree of names, not just one, and is
otherwise only discoverable by noticing traffic silently going to the
wrong container.
Unlike an exact-match alias, which works by writing an entry into
/etc/hosts, a wildcarded one is resolved by fghjd’s own DNS server
(/etc/hosts has no wildcard syntax) — see
Split DNS.
Wildcarding the default domain
Section titled “Wildcarding the default domain”additional_hosts can’t name anything inside fghj.internal — that
domain is always derived, never author-declared. To wildcard a node’s own
default domain instead (so it, too, matches every subdomain of itself, not
just the exact name), set wildcard: true on the #Port that’s primary
and/or named — see Ports above.
Reaching the TLS proxy from inside a container
Section titled “Reaching the TLS proxy from inside a container”Every node actually has two derived domains — a raw one
(*.fghj.raw.internal) and an HTTP(S)-proxied one (*.fghj.internal) —
covered in full in Node identity &
domains
and Split DNS. From inside a service’s own
container, a sibling’s raw domain resolves straight to that sibling’s
container IP via Docker’s own embedded per-network DNS — the right
behavior for a node’s own container port (a database’s 5432, an internal
API’s own port): fastest path, no extra hop, no TLS.
A sibling’s http domain, from inside the same container, resolves
instead to this run’s TLS proxy sidecar — the exact same SNI-dispatch
logic, same hostname, same port (443/80), that a browser or host
process outside the network gets when it dials that name. This is
automatic for every node in a run: nothing to declare, no extra_hosts
entry, no sentinel. Use the http domain (via
${FGHJ_SERVICE_FQDN_HTTP:name} — see Domain templates in
environment above) for anything that
specifically needs the same HTTPS hostname a browser or host process
would use — most commonly, a service that mints URLs meant to be handed
back out (a presigned S3 URL, an OAuth redirect, a webhook callback) and
can only have one endpoint configured for both its own calls and the URLs
it generates.
The container also needs to trust fghj’s local CA for that TLS connection to succeed — see Local CA & TLS proxy.
This mechanism doesn’t depend on any container-runtime-specific gateway forwarding — it works the same way on Docker Desktop, OrbStack, and native Linux Docker Engine alike.
Healthcheck & start order
Section titled “Healthcheck & start order”services: api: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 10 timeout: 5 start_period: 30 retries: 3| Field | Type | Description |
|---|---|---|
test | list of strings | The command Docker runs to check health, e.g. ["CMD", "pg_isready"] — same shape as Docker’s own HEALTHCHECK CMD. |
interval / timeout / start_period | seconds, optional | Same semantics as Docker’s HEALTHCHECK options of the same name (given in seconds here, not nanoseconds). |
retries | integer, optional | Consecutive failures before Docker marks the container unhealthy. |
There’s no separate depends_on: {condition: ...} field — a node that
declares healthcheck is automatically waited on: any run that starts it
blocks (up to two minutes) until Docker reports it healthy before moving
on to the nodes that depend on it. A node with no healthcheck behaves
exactly as before — dependents proceed as soon as it’s started, not
waiting on anything. This applies to both fghj run (starting a fresh run)
and picking a flow (ensure_running); containers within a run always start
in dependency order (depends-on/owns edges), not workspace-scan order.
Dependencies
Section titled “Dependencies”Three kinds, distinguished by kind:
kind: service
Section titled “kind: service”A dependency on another self-describing service repo, resolved by cloning it into the workspace (folder named after the repo URL’s last path segment).
# target repo declares one service: omit `services`, it's used automatically- kind: service repo: git@github.com:acme/notifications-service.git default_branch: main
# target repo declares several: one block per repo still, name each one you need- kind: service repo: git@github.com:acme/payments-service.git default_branch: main services: [payments-api, payments-worker]| Field | Description |
|---|---|
repo | Git URL — git@…, https://…, or ssh://…. |
services | Which of the target repo’s services this depends on — a list, so depending on several from the same repo is still one block (repo/default_branch stated once), not one block per service. Omit when that repo declares exactly one service (used automatically); required when it declares more than one. Each name gets its own depends-on edge; a name that doesn’t exist there is a warning, not a hard fghj validate failure. |
default_branch | The branch cloned by default. This is only ever a default — never a live pin; see Branch ownership model. |
kind: backing
Section titled “kind: backing”A dependency on a backing service — a datastore, broker, or similar —
provisioned directly from an image. Nothing to clone, no .fghj.yaml of
its own. The declaring service owns this instance; other services can
bind to the same instance via kind: shared-backing below.
- kind: backing name: postgres image: postgres:16 ports: ["5432"] environment: POSTGRES_PASSWORD: dev domain_scope: run command: ["mysqld", "--sql_mode=NO_ENGINE_SUBSTITUTION"] platform: linux/amd64 env_file: - .env.postgres restart: unless-stopped healthcheck: test: ["CMD", "pg_isready"] interval: 5 retries: 5 volumes: - name: pgdata container: /var/lib/postgresql/data| Field | Description |
|---|---|
name | Lowercase label, unique among this service’s own backing dependencies. |
image | Docker image reference. |
ports | Either a bare list of container ports to publish (["5432"]), or the same {port: #Port} map form service.ports uses (see Ports above) — e.g. minio’s S3 API and web console can each get their own primary/name/wildcard/host_port this way, exactly like a service’s own ports. |
environment | Same shape as service.environment. |
domain_scope | "run" (default) or "stable" — same semantics as service.domain_scope. |
command | Same shape as service.command — overrides the image’s default CMD, e.g. to pass extra startup flags to a stock database image. |
platform | Pins the image’s platform (os[/arch[/variant]], e.g. linux/amd64) — for a backing image only published for one architecture, so Docker’s platform-aware pull/lookup gets the right one. |
env_file | Same shape as service.env_file, but resolved differently: since a backing dependency has no checkout of its own, each path resolves against the declaring service’s checkout root instead — the same rule Compose uses, resolving env_file against the compose file’s own directory regardless of build vs image. |
restart / user / working_dir / labels / cap_add / cap_drop / privileged / extra_hosts / healthcheck | Same shape and meaning as the equally-named service.* fields above. |
volumes | Same shape as service.volumes — see Volumes. A volume’s own scope (default "run") governs its lifecycle independently of this backing dependency’s domain_scope; a named volume here (like pgdata above) is what makes the data survive a restart. |
kind: shared-backing
Section titled “kind: shared-backing”A reference to a kind: backing dependency already owned by another
service in the resolved graph — binds to that same running instance
instead of provisioning a second one. The owning service can be in
another repo, or a sibling service declared in this same repo’s
services map — e.g. a vite dev-server service and a php service
built from the same repo, where php owns a mysql backing dependency
that vite also needs to reach:
# cross-repo: omit `service` if the target repo only declares one- kind: shared-backing repo: git@github.com:acme/payments-service.git service: payments-api name: postgres
# same-repo: omit `repo` entirely — refers to a sibling service# declared in this repo's own `services` map- kind: shared-backing service: php name: mysql| Field | Description |
|---|---|
repo | The Git URL of the repo that owns the backing dependency. Omit to reference a sibling service in this same repo instead. |
service | The name of the service (in repo, or in this repo if repo is omitted) that owns the backing dependency. |
name | Must match the owning service’s declared backing dependency name exactly. A reference that doesn’t resolve is flagged as a warning, not a hard failure — the owning repo might just not be cloned yet. |
flows: checkout: description: End-to-end checkout journey service: cart-service dependencies: - kind: service repo: git@github.com:acme/payments-service.git default_branch: mainAny repo can declare zero or more flows — there’s no distinguished “root”
repo; see Flat workspace model. Each
flow is a named user journey: a description plus an additional list of
dependencies (same three kinds as above) pulled in only when that flow is
selected, on top of the service’s own baseline dependencies.
service says which of this repo’s services the flow is rooted at.
Omit it when the repo declares exactly one service (it’s used
automatically); required when it declares more than one, since there’s
no other way to tell which service’s dependencies the flow is actually
describing. An ambiguous or missing reference is a warning, not a hard
fghj validate failure.
A flow’s dependencies list must be non-empty — a flow with zero extra
dependencies isn’t meaningfully different from the service’s baseline
graph.