Skip to content

.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.

version: "1.0"
services:
<service-name>:
# ... see Services below
flows:
<flow-name>:
# ... see Flows below

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
FieldTypeDescription
build.contextstringDocker build context. Defaults to ..
build.dockerfilestringDockerfile path, relative to context. Defaults to Dockerfile.
build.argsmap of string→stringBuild-time --build-arg values.
portsmap of container-port→#PortDeclared 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.
environmentmap or listEither {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_filelist 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.
platformstring, optionalPins 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.
commandlist of stringsOverrides 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.
userstring, optionalOverrides the image’s default container user, e.g. "1000:1000" or "postgres".
working_dirstring, optionalOverrides the image’s default working directory.
labelsmap of string→stringExtra container labels, merged under fghj’s own com.docker.compose.* labels — fghj’s own always win on a key conflict.
cap_add / cap_droplist of stringsLinux capabilities to add/drop — Compose’s cap_add/cap_drop.
privilegedboolRuns the container with extended, near-host-equivalent privileges. Defaults to false — only set this for a real, specific need.
extra_hostslist of "hostname:ip" stringsExtra 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, optionalA Docker HEALTHCHECK. See Healthcheck & start order below.
volumeslist of #VolumeBind mounts and named volumes. See Volumes below.
additional_hostslist of #HostAliasExtra 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.
dependencieslist of #DependencyThis 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
FieldTypeDescription
primaryboolAt most one port per service should set this. Puts the port at the service’s own domain (cart.myworkspace.fghj.internal). Defaults to false.
namestring, optionalGives the port an additional nested domain: {name}.{service's domain}. Can be combined with primary.
host_port1–65535, optionalPin 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.
wildcardboolWhen 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.

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}/
TokenResolves 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.

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.

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/src

host 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
FieldTypeDescription
hoststringA host path. Relative paths resolve against this repo’s checkout root; absolute or ..-escaping paths pass through unchanged.
containerstringMount path inside the container.
read_onlyboolMounts read-only. Defaults to false.

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/data

name 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.yaml
volumes:
- name: shared-cache
container: /app/.cache
# service B's .fghj.yaml — same name, same scope, same volume
volumes:
- name: shared-cache
container: /var/cache/app
FieldTypeDescription
namestringA 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.
containerstringMount path inside the container.
read_onlyboolMounts 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.

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

Requires 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.internal name, but only while some running container actually claims it (aikido.local above).
  • 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.io above).

An alias can never sit inside fghj.internal itself — that domain is always derived, never author-declared, the same rule ports’ name field follows.

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

With 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.

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.

services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10
timeout: 5
start_period: 30
retries: 3
FieldTypeDescription
testlist of stringsThe command Docker runs to check health, e.g. ["CMD", "pg_isready"] — same shape as Docker’s own HEALTHCHECK CMD.
interval / timeout / start_periodseconds, optionalSame semantics as Docker’s HEALTHCHECK options of the same name (given in seconds here, not nanoseconds).
retriesinteger, optionalConsecutive 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.

Three kinds, distinguished by kind:

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]
FieldDescription
repoGit URL — git@…, https://…, or ssh://….
servicesWhich 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_branchThe branch cloned by default. This is only ever a default — never a live pin; see Branch ownership model.

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
FieldDescription
nameLowercase label, unique among this service’s own backing dependencies.
imageDocker image reference.
portsEither 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.
environmentSame shape as service.environment.
domain_scope"run" (default) or "stable" — same semantics as service.domain_scope.
commandSame shape as service.command — overrides the image’s default CMD, e.g. to pass extra startup flags to a stock database image.
platformPins 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_fileSame 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 / healthcheckSame shape and meaning as the equally-named service.* fields above.
volumesSame 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.

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
FieldDescription
repoThe Git URL of the repo that owns the backing dependency. Omit to reference a sibling service in this same repo instead.
serviceThe name of the service (in repo, or in this repo if repo is omitted) that owns the backing dependency.
nameMust 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: main

Any 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.