When to go multi-component
Everything we've built so far has been a single-component app — one runtime, one start command, one process. That covers most applications. But when your project has a separate frontend and backend (or background workers, or a websocket server), you need the components map.
Instead of a flat Launchfile, you nest each service under a key in components:. Each component gets its own runtime, commands, ports, and health checks. The provider launches them together and wires them up.
Components can depend on each other using depends_on — the provider ensures the backend is healthy before starting the frontend.
Build it up
Let's build HedgeDoc — a collaborative markdown editor with a Node backend and a separate frontend — step by step.
components: — it's the same fields, just nested.name: hedgedoc
components:
backend:
runtime: node
commands:
start: "node dist/main.js" provides to expose the API on port 3000, and requires to declare the Postgres dependency. Same fields you already know.name: hedgedoc
components:
backend:
runtime: node
provides:
- name: api
protocol: http
port: 3000
requires:
- type: postgres
set_env:
HD_DATABASE_URL: $url
commands:
start: "node dist/main.js" release command runs migrations before each deploy.name: hedgedoc
components:
backend:
runtime: node
provides:
- name: api
protocol: http
port: 3000
requires:
- type: postgres
set_env:
HD_DATABASE_URL: $url
commands:
start: "node dist/main.js"
release: "node dist/migrations.js"
health:
path: /api/private/config
start_period: 30s depends_on the backend (waits until healthy), exposes port 3001 to the internet, and uses $components.backend.url to discover the backend's address.name: hedgedoc
components:
backend:
runtime: node
provides:
- name: api
protocol: http
port: 3000
requires:
- type: postgres
set_env:
HD_DATABASE_URL: $url
commands:
start: "node dist/main.js"
release: "node dist/migrations.js"
health:
path: /api/private/config
start_period: 30s
frontend:
runtime: node
depends_on:
- component: backend
condition: healthy
provides:
- protocol: http
port: 3001
exposed: true
env:
HD_BASE_URL:
default: $components.backend.url version: launch/v1
name: hedgedoc
description: "Collaborative markdown editor"
components:
backend:
runtime: node
build:
dockerfile: ./docker/Dockerfile.backend
provides:
- name: api
protocol: http
port: 3000
bind: "0.0.0.0"
spec:
openapi: file:docs/openapi.yaml
requires:
- type: postgres
version: ">=15"
set_env:
HD_DATABASE_URL: $url
HD_DATABASE_USERNAME: $user
HD_DATABASE_PASSWORD: $password
HD_DATABASE_NAME: $name
env:
HD_AUTH_SESSION_SECRET:
generator: secret
sensitive: true
HD_BACKEND_BIND_IP:
default: "0.0.0.0"
commands:
start: "node dist/main.js"
release: "node dist/migrations.js"
health:
path: /api/private/config
start_period: 30s
frontend:
runtime: node
build:
dockerfile: ./docker/Dockerfile.frontend
depends_on:
- component: backend
condition: healthy
provides:
- protocol: http
port: 3001
exposed: true
env:
HD_BASE_URL:
default: $components.backend.url
description: "Backend URL for SSR API calls" Cross-component wiring
The expression $components.backend.url resolves to the backend's internal URL at deploy time. No hardcoded ports, no guessing — the provider handles the wiring.
Notice that only the frontend has exposed: true. That's because the frontend is the public-facing entry point — users hit the frontend, which talks to the backend internally. The backend stays on the private network, inaccessible from the outside.
This pattern scales to any number of components. A backend, a frontend, a websocket server, a background worker — each gets a key in components:, declares its own ports and dependencies, and uses expressions to discover the others.
In the wild
Here's the full HedgeDoc Launchfile from the spec examples. This is the same file we built step by step — now with all the production details.
version: launch/v1
name: hedgedoc
description: "Collaborative markdown editor"
components:
backend:
runtime: node
build:
dockerfile: ./docker/Dockerfile.backend
provides:
- name: api
protocol: http
port: 3000
bind: "0.0.0.0"
spec:
openapi: file:docs/openapi.yaml
requires:
- type: postgres
version: ">=15"
set_env:
HD_DATABASE_URL: $url
HD_DATABASE_USERNAME: $user
HD_DATABASE_PASSWORD: $password
HD_DATABASE_NAME: $name
env:
HD_AUTH_SESSION_SECRET:
generator: secret
sensitive: true
HD_BACKEND_BIND_IP:
default: "0.0.0.0"
commands:
start: "node dist/main.js"
release: "node dist/migrations.js"
health:
path: /api/private/config
start_period: 30s
frontend:
runtime: node
build:
dockerfile: ./docker/Dockerfile.frontend
depends_on:
- component: backend
condition: healthy
provides:
- protocol: http
port: 3001
exposed: true
env:
HD_BASE_URL:
default: $components.backend.url
description: "Backend URL for SSR API calls" depends_on:- The frontend waits for the backend to pass its health check before starting.
$components.backend.url- The provider resolves this to the backend's internal URL — no hardcoded addresses.
exposed: true- Only the frontend is public-facing. The backend stays on the internal network.
Check your understanding
- Use
components:when your app has multiple services (frontend, backend, workers). depends_oncontrols startup order — a component waits until its dependency is healthy.$components.<name>.urllets components discover each other without hardcoded addresses.- Only public-facing components need
exposed: true— keep backends internal.