Introduction
LightShuttle is a developer-time orchestrator written in Rust. You declare
your service stack once in lightshuttle.yml (databases, queues, containers,
Dockerfiles), and lightshuttle up boots the whole thing on your laptop with
automatic service discovery, an integrated web dashboard, OpenTelemetry traces
and logs, and a one-command export to docker-compose.yml, Kubernetes
manifests or a Helm chart for production.
LightShuttle is sponsored by Nubster and dual-licensed under MIT or Apache-2.0.
Install
LightShuttle needs a running Docker daemon and a Rust toolchain
(rustup is the recommended installer). Install the CLI
from crates.io:
cargo install lightshuttle
Confirm the install:
$ lightshuttle --version
lightshuttle 0.4.0
Quickstart
Create a lightshuttle.yml at the root of your project:
project:
name: hello
resources:
db:
postgres:
version: "16"
Boot the stack:
lightshuttle up
LightShuttle validates the manifest, pulls the image, starts Postgres, waits
for the healthcheck to pass and supervises the container until you press
Ctrl+C. Shutdown is coordinated and idempotent.
How this documentation is organised
This site follows the Diátaxis framework: four kinds of documentation, each serving a different need.
- Tutorials are lessons that take you by the hand through a series of steps. Start here if you are new.
- How-to guides are recipes for solving a specific task once you know the basics.
- Reference is the exhaustive, normative description of the manifest, the CLI and the public APIs.
- Explanation discusses the design and the reasoning behind it.
Status
LightShuttle is published on crates.io and under active development. The public API is pre-1.0 and may change between minor versions; see the SemVer policy and the roadmap.
Getting started with LightShuttle
This tutorial takes about ten minutes. By the end you will have booted a two-service stack on your laptop with a single command, observed it through the CLI, shut it down cleanly and extended it with a second backing service. No prior Rust knowledge is required; basic familiarity with Docker is assumed.
Pre-1.0. LightShuttle is published and works end to end on a real Docker daemon, but the public API may still change between minor versions. See the SemVer policy.
A reminder of what LightShuttle is not, so the rest of the tutorial is read with the right expectations:
- Not a production runtime.
- Not a Kubernetes replacement.
- Not a service mesh.
- Not a CI/CD pipeline.
LightShuttle is the local stack runner you reach for instead of
docker-compose while you are coding. When you are ready to ship,
lightshuttle export turns the same manifest into a
docker-compose.yml, Kubernetes manifests or a Helm chart.
Prerequisites
You need:
- A running Docker daemon. Docker Desktop on macOS or Windows works out
of the box; on Linux any modern Docker Engine or
colimaworks. - A Rust toolchain. The recommended way to install it is
rustup. LightShuttle’s MSRV is documented indocs/MSRV_POLICY.md. - A terminal. Examples below use a POSIX-style shell. On Windows, PowerShell works fine; replace the line continuation backticks if you copy-paste multi-line commands.
Verify Docker is reachable:
$ docker version --format '{{.Server.Version}}'
27.3.1
If that command fails, start Docker before continuing.
Step 1: Install LightShuttle
Install the CLI from crates.io:
$ cargo install lightshuttle
Cargo compiles the binary in release mode and drops it in
~/.cargo/bin/lightshuttle. Confirm the install:
$ lightshuttle --version
lightshuttle 0.4.0
If lightshuttle is not found, make sure ~/.cargo/bin is on your
PATH.
Optional: shell alias lsh
Typing lightshuttle twelve times an hour gets old. A short alias
makes the binary feel like a native command. We deliberately did
not ship lsh as the default binary name to avoid colliding with
the legacy GNU lsh SSH client still packaged on some Linux
distributions, but you can opt in if your environment is free of it.
Automatic setup (recommended)
lightshuttle alias install detects your shell, refuses to run when a
conflicting lsh executable is on your PATH, and writes the alias to
the right startup file:
$ lightshuttle alias install
Detected shell: zsh
Will add `alias lsh='lightshuttle'` to /home/you/.zshrc
Proceed? [y/N]: y
ok: added `lsh` alias. Restart your shell or reload /home/you/.zshrc
It is idempotent, so re-running it is a no-op. Companion commands:
lightshuttle alias checkreports whatinstallwould do without writing anything.lightshuttle alias uninstallremoves the alias.--shell <bash|zsh|fish|powershell>overrides auto-detection and--yesskips the prompt for scripts and CI.
cmd.exe has no startup file, so there it stays manual; use PowerShell
or the .bat shim described below.
The rest of this section documents the manual procedure for reference.
Check availability
Before adding the alias, confirm nothing else owns lsh on your
machine:
# Linux / macOS
$ command -v lsh
# Windows PowerShell
PS> Get-Command lsh -ErrorAction SilentlyContinue
If the command prints a path (typically /usr/bin/lsh or similar),
something else is already there. Stick with the full lightshuttle
name to avoid silent confusion.
If the command returns nothing, you are clear to alias.
Add the alias
bash or zsh — append to ~/.bashrc or ~/.zshrc:
alias lsh='lightshuttle'
Reload with source ~/.bashrc (or open a new terminal).
fish — once per shell session, or persisted with funcsave:
alias --save lsh='lightshuttle'
PowerShell — append to your profile ($PROFILE):
Set-Alias -Name lsh -Value lightshuttle
Open a new PowerShell window to pick it up.
Windows cmd.exe has no native alias mechanism for executables.
Either use PowerShell, or drop a one-line lsh.bat shim somewhere on
your PATH:
@echo off
lightshuttle %*
Verify
$ lsh --version
lightshuttle 0.4.0
If anything in this tutorial reads lightshuttle, you can substitute
lsh from this point on.
Step 2: Your first manifest
Create a fresh directory and an empty lightshuttle.yml next to it.
$ mkdir hello-lightshuttle && cd hello-lightshuttle
Open lightshuttle.yml in your editor and paste:
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: hello
resources:
db:
postgres:
version: "16"
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo connected to $DATABASE_URL && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
Line by line:
- The
yaml-language-servermodeline points editors at the JSON Schema shipped with the spec. With it, Visual Studio Code, IntelliJ IDEs and neovim provide autocompletion and inline validation. It is optional but recommended. project.nameidentifies the stack. The orchestrator uses it as a prefix for every container it creates, so two LightShuttle projects never collide.- The
resourcessection is a map of resource names to resource definitions. Each entry has exactly one kind key (postgres,redis,container,dockerfile). dbis a Postgres 16 instance. With no further configuration, the runtime expandsversion: "16"into the officialpostgres:16-alpineimage, generates a random password and binds an auto-named persistent volume.appis a plain container based onalpine:3.20. Itsenvblock uses the interpolation form${resources.db.url}, which the orchestrator resolves at boot to the full Postgres URL of thedbresource. That reference also creates an implicit dependency:appwill not start untildbis healthy.
For the full grammar see the manifest specification.
Step 3: Boot the stack
LightShuttle exposes three commands you typically chain while iterating on a manifest:
$ lightshuttle validate
ok: project `hello` with 2 resource(s)
validate parses the file, resolves every interpolation and checks the
dependency graph without touching Docker. Use --strict in continuous
integration to upgrade warnings to errors.
$ lightshuttle manifest
manifest prints the fully resolved YAML to stdout: defaults are
materialised, interpolations are expanded with the values that will be
used at runtime. It is the source of truth when you debug “why did my
container get that environment variable”.
$ lightshuttle up
up boots the stack:
- The manifest is validated.
- Resources are started in topological order.
dbstarts first. - The orchestrator polls the Postgres healthcheck until it succeeds.
appstarts, withDATABASE_URLinjected and pointing atdb.- The process stays in the foreground, supervising containers, until
you press
Ctrl+C.
You will see lines similar to:
project `hello`: starting 2 resource(s)
db: starting
db: healthy
app: starting
app: running
Step 4: Observe
In a second terminal, list what is running:
$ lightshuttle ps
NAME KIND STATUS READY IMAGE
db postgres running yes postgres:16-alpine
app container running yes alpine:3.20
Stream the application’s logs:
$ lightshuttle logs app
connected to postgres://postgres:<generated>@db:5432/db
Add --follow (or -f) to keep tailing.
Notice the hostname in that URL: db, the resource name. Each project
runs on its own Docker bridge network (lightshuttle-<project>), and
every container joins it with a DNS alias equal to its resource name,
so containers reach each other by name. Two LightShuttle projects
running side by side stay isolated: they sit on different networks.
Step 5: Shutdown
Back in the first terminal, press Ctrl+C. LightShuttle sends
SIGTERM in reverse topological order, gives each container ten
seconds to exit cleanly, then escalates to SIGKILL if needed.
If anything is left over (for example you closed the laptop), run:
$ lightshuttle down
stopped: app
stopped: db
down is idempotent: running it a second time prints
nothing to stop for project hello.
Step 6: Multi-resource stack
Real applications need more than one backing service. Extend the manifest with a Redis cache:
project:
name: hello
resources:
api_db:
postgres:
version: "16"
cache:
redis:
version: "7"
app:
container:
image: alpine:3.20
command:
- sh
- -c
- |
echo "db = $DATABASE_URL"
echo "redis= $REDIS_URL"
echo "db host (auto) = $LSH_API_DB_HOST"
sleep 3600
env:
DATABASE_URL: ${resources.api_db.url}
REDIS_URL: ${resources.cache.url}
Boot it:
$ lightshuttle up
project `hello`: starting 3 resource(s)
api_db: starting
cache: starting
api_db: healthy
cache: healthy
app: starting
app: running
api_db and cache start in parallel because they have no dependency
between them. app waits for both before starting.
Two ways to consume a resource
The app container reads three environment variables. Two of them are
declared explicitly in the manifest via interpolation:
DATABASE_URLfrom${resources.api_db.url}.REDIS_URLfrom${resources.cache.url}.
The third, LSH_API_DB_HOST, is injected automatically by the
runtime. For every dependency, LightShuttle exposes each property of
the dependency as an environment variable named
LSH_<DEP>_<PROPERTY>, upper-cased. With api_db as a dependency, the
container therefore receives:
| Variable | Source |
|---|---|
LSH_API_DB_HOST | ${resources.api_db.host} |
LSH_API_DB_PORT | ${resources.api_db.port} |
LSH_API_DB_DATABASE | ${resources.api_db.database} |
LSH_API_DB_USER | ${resources.api_db.user} |
LSH_API_DB_PASSWORD | ${resources.api_db.password} |
LSH_API_DB_URL | ${resources.api_db.url} |
The same pattern applies to cache: LSH_CACHE_HOST,
LSH_CACHE_PORT, LSH_CACHE_URL, and so on.
Two consumption styles coexist on purpose. Explicit interpolation keeps
your application portable: it reads the standard DATABASE_URL that
every language ecosystem already understands. The automatic
LSH_<DEP>_<PROP> variables give you a zero-configuration escape hatch
when you want to wire a quick script without editing the manifest.
Shut everything down:
$ # Ctrl+C in the foreground terminal, then:
$ lightshuttle down
Step 7: Secrets from a .env file
The manifest is committed to version control; secrets must not be.
Since v0.4.0, ${env.<NAME>} references resolve from a .env file or
the process environment, with the file taking precedence. This section
covers the essentials; for the full workflow (optional values, CI gates,
divergent defaults) see the how-to guide
Manage secrets and environment variables.
Add a secret to the app resource:
app:
container:
image: alpine:3.20
command:
- sh
- -c
- |
echo "db = $DATABASE_URL"
echo "token = $API_TOKEN"
sleep 3600
env:
DATABASE_URL: ${resources.api_db.url}
REDIS_URL: ${resources.cache.url}
API_TOKEN: ${env.API_TOKEN}
Create a .env file next to the manifest, and add it to your
.gitignore:
$ echo 'API_TOKEN=dev-secret-token' > .env
Before booting, audit what the stack needs:
$ lightshuttle secrets check
secrets for project `hello`:
API_TOKEN set (.env)
all required secrets are set
Now remove the line from .env and run the check again: the variable
is reported missing and the command exits non-zero, which makes it a
cheap CI gate. lightshuttle up applies the same rule and refuses to
boot while a required variable is missing, so a misconfigured stack
fails fast instead of half-starting.
A reference without a default (${env.API_TOKEN}) is required; the
form ${env.API_TOKEN:-fallback} makes it optional. Both up and
secrets check accept --env-file <path> to point at another file.
What’s next
- Read the manifest specification for every supported field, resource kind and interpolation rule.
- Explore the dashboard tutorial for the web UI, live logs and the OpenTelemetry collector.
- Generate deployment artifacts with the export tutorial.
- Browse the
examples/folder for ready-to-run manifests. - Track upcoming features in the roadmap.
- To contribute, read
CONTRIBUTING.mdandSECURITY.mdfirst.
Dashboard walkthrough
The LightShuttle dashboard is served on the same 127.0.0.1:<port>
the orchestrator boots when you run lightshuttle up. The URL is
printed to the terminal and saved to .lightshuttle/control.url for
discovery by other client commands. This walkthrough boots a small
three-resource stack and visits each dashboard page.
1. Boot a three-resource stack
Use the example shipped at examples/03-full-stack:
project:
name: app
resources:
db:
postgres:
version: '16'
cache:
redis:
version: '7'
api:
container:
image: alpine
depends_on: [db, cache]
From that directory:
lightshuttle up
The boot log advertises the dashboard URL in colour, for example:
LightShuttle dashboard ready at http://127.0.0.1:54321/
Open that URL in a browser.
2. Index page (/)
The index lists every resource declared in the manifest:
| Name | Kind | Status | Healthy | Image | Actions |
|---|---|---|---|---|---|
| db | postgres | running | yes | postgres:16-alpine | Restart |
| cache | redis | running | yes | redis:7-alpine | Restart |
| api | container | starting | no | alpine:3.20 | Restart |
- The status column refreshes every two seconds via an HTMX poll on
/_partials/resources. No full page reload, no flicker. - Clicking Restart posts to
/api/resources/{name}/restartand returns 202 immediately. The poll picks up the cycle on the next refresh. - Each row name is a link to the detail page.
3. Resource detail (/resources/api)
The detail page shows the full metadata block:
- Kind:
container - Status:
running - Healthy: yes
- Image:
alpine:3.20 - Last error: only when a terminal failure has occurred.
Below the metadata, a live log pane streams stdout and stderr of
the resource via the /ws/logs/{name} WebSocket. The pane scrolls
automatically; [stderr] entries are prefixed for distinction.
The page also exposes a Restart this resource button targeting the same REST endpoint as the index.
4. Visual smoke checklist
When you visit the running dashboard for the first time, the following should be true:
-
GET /returns HTML containing the project badge and one row per resource. - The HTMX library is fetched from
/_assets/htmx.min.jsand the stylesheet from/_assets/style.css. Both responses include aCache-Control: public, max-age=3600header. - Stopping a container externally (for example, via the system Docker CLI) is reflected on the index within two seconds.
- Clicking Restart on a running resource shows the row going
running→starting→runningagain without a full page reload. -
GET /resources/{unknown}returns404 Not Found. - The detail log pane begins streaming as soon as the page loads
and reports
[log stream closed]when the resource is stopped.
When all six boxes are ticked, the dashboard is working as intended.
Tutorial: export to deployment artifacts
lightshuttle up runs your stack locally. When it is time to ship,
lightshuttle export turns the same manifest into the deployment format
your platform expects, with no second source of truth to keep in sync.
This walkthrough uses examples/04-export,
whose manifest carries an export: section that tailors each target.
The manifest
project:
name: shop
version: "1.2.0"
export:
compose:
resources:
worker:
enabled: false # a dev-only helper, left out of compose
kubernetes:
namespace: shop-staging
replicas: 2
resources:
api:
replicas: 3 # the API scales wider than the default
helm:
chart_name: shop
chart_version: "1.2.0"
resources:
db:
postgres:
version: "16"
database: shop
password: ${env.SHOP_DB_PASSWORD:-change-me-in-your-vault}
api:
container:
image: nginx:1.27-alpine
ports:
- 8080:80
env:
LOG_LEVEL: info
API_TOKEN: ${env.SHOP_API_TOKEN:-change-me-in-your-vault}
depends_on: [db]
worker:
container:
image: alpine:3.20
command: ["sh", "-c", "echo worker booting && sleep 3600"]
The export: section is optional. Without it every target still
generates valid output using the defaults described in the
export specification.
Docker Compose
lightshuttle export compose
Writes ./export/compose/docker-compose.yml. The db and api services
appear; worker is omitted because the manifest disables it for compose.
Published ports bind to 127.0.0.1 and api waits on db becoming
healthy.
Validate it with the Compose CLI:
docker compose -f export/compose/docker-compose.yml config
Kubernetes
lightshuttle export kubernetes
Writes one file per resource plus a namespace:
export/kubernetes/
namespace.yaml
db.yaml # Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim
api.yaml # Deployment (replicas: 3), Service, ConfigMap, Secret
worker.yaml
The namespace is shop-staging, api runs three replicas (its
per-resource override) and the rest run two (the target default).
API_TOKEN lands in a Secret, LOG_LEVEL in a ConfigMap.
Validate the manifests offline with
kubeconform:
kubeconform -strict export/kubernetes/*.yaml
kubectl apply --dry-run=client is avoided here because it contacts the
cluster to download the schema, which makes it depend on your kubeconfig.
Helm
lightshuttle export helm
Writes a chart:
export/helm/
Chart.yaml # name: shop, version: 1.2.0
values.yaml # per-service replicas, image and env/secrets
templates/
db.yaml
api.yaml
worker.yaml
The knobs live in values.yaml, so a downstream operator can override
replicas or the image tag without editing templates. Validate the chart:
helm lint export/helm
Output options
--output <dir>writes elsewhere than./export/<target>.--forceoverwrites a non-empty output directory.
A note on secrets
The manifest resolves its secrets through ${env.*} references with
explicit defaults: the export stays reproducible out of the box, and
real values override the placeholders from a .env file or the
process environment (the file wins). Audit the resolution before
exporting:
lightshuttle secrets check
A postgres or redis resource without any password resolves to a
freshly generated one on every run, which is handy for up but means
each export bakes a different value: keep an explicit password or an
${env.*} reference with a default when you export. Real deployments
should still source production secrets from a vault, not from the
exported files.
For the full mapping rules see the export specification.
Onboarding by language
The getting started tutorial is deliberately
language agnostic: it boots alpine stubs that only echo their
environment. These onboarding tutorials do the opposite. Each one takes
a single programming stack and walks you from an empty directory to a
real HTTP service that connects to Postgres, all booted with one
lightshuttle up.
Pick your stack:
Every tutorial follows the same arc, so once you have done one the others read quickly:
- Scaffold an empty project directory.
- Write a small HTTP service that reads
DATABASE_URLand runs one query. - Add a
Dockerfileso LightShuttle builds the service for you. - Declare a two-resource manifest: a Postgres database and your service.
- Boot, observe through the CLI, visit the dashboard, shut down.
What you need
- A running Docker daemon.
- The LightShuttle CLI. If you have not installed it yet, follow Step 1 of getting started.
You do not need a local toolchain for the language you pick. The
service is compiled and run inside a container that LightShuttle builds
from your Dockerfile, so an empty machine with only Docker and the
CLI is enough. That is the whole point: the same workflow regardless of
the stack underneath.
What the services have in common
| Stack | HTTP layer | Postgres driver | Base image |
|---|---|---|---|
| Node.js | built-in node:http | pg | node:22-alpine |
| Python | built-in http.server | psycopg 3 | python:3.12-slim |
| Go | net/http | pgx v5 | golang:1.23-alpine |
| Rust | axum | tokio-postgres | rust:1.83-slim |
Each service exposes a single route:
GET / -> 200 {"db":"ok","now":"2026-..."}
The handler runs select now() against the database and returns the
result as JSON. It is intentionally tiny: the value is seeing the same
LightShuttle workflow wrap four very different ecosystems without
changing a single command.
When you are done, read the manifest specification for every field these manifests use, or jump to the dashboard walkthrough for the web UI in depth.
Onboarding: Node.js
This tutorial takes about fifteen minutes. You will build a small
Node.js HTTP service that queries Postgres, then boot it next to a
database with a single lightshuttle up. You do not need Node installed
locally: LightShuttle builds the service inside a container from the
Dockerfile you write.
If you have not installed the CLI yet, do Step 1 of getting started first, then come back.
Step 1: Scaffold the project
Create an empty directory and move into it:
$ mkdir onboarding-node && cd onboarding-node
By the end you will have four files in it:
onboarding-node/
server.js the HTTP service
package.json its single dependency
Dockerfile how LightShuttle builds it
lightshuttle.yml the stack: Postgres + the service
Step 2: Write the service
The service reads the connection string from DATABASE_URL, runs one
query on each request, and answers with JSON. Create server.js:
const http = require("node:http");
const { Pool } = require("pg");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const port = Number(process.env.PORT) || 8080;
const server = http.createServer(async (req, res) => {
if (req.url !== "/") {
res.writeHead(404).end("not found\n");
return;
}
try {
const { rows } = await pool.query("select now() as now");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ db: "ok", now: rows[0].now }));
} catch (error) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ db: "error", message: String(error) }));
}
});
server.listen(port, () => console.log(`api listening on ${port}`));
Two things are worth noting:
DATABASE_URLis never hard-coded. LightShuttle injects it at boot, pointing at the database resource. The same code runs unchanged against any Postgres.- The handler opens no connection of its own at startup. The
Poolconnects lazily on the first query, so the service starts even if the database needs a moment to become reachable.
Declare the one dependency in package.json:
{
"name": "onboarding-node",
"version": "0.1.0",
"private": true,
"dependencies": {
"pg": "^8.13.0"
}
}
Step 3: Write the Dockerfile
LightShuttle builds the service from this Dockerfile. A two-stage
layout keeps dependency installation cached separately from your source,
so editing server.js does not reinstall pg:
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
FROM base AS dev
COPY server.js ./
EXPOSE 8080
CMD ["node", "server.js"]
The manifest will select the dev stage explicitly through
target: dev. A real project would add a leaner release stage on top
of the same base; here one stage is enough.
Step 4: Write the manifest
Now tie the two resources together. Create lightshuttle.yml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: onboarding-node
resources:
db:
postgres:
version: "16"
api:
dockerfile:
context: .
target: dev
env:
DATABASE_URL: ${resources.db.url}
ports:
- 8080
What each part does:
dbis a Postgres 16 instance. LightShuttle expands it to the officialpostgres:16-alpineimage, generates a password and binds a persistent volume.apiis built from theDockerfilein the current directory (context: .), selecting thedevstage.env.DATABASE_URLis set to${resources.db.url}. That interpolation resolves at boot to the full Postgres URL ofdb, and it also makesapidepend ondb: the service will not start until the database is healthy. No explicitdepends_onis needed.ports: [8080]publishes the container port on your host so you can reach the service from a browser orcurl.
Step 5: Boot the stack
Validate first. This parses the manifest and resolves interpolations without touching Docker:
$ lightshuttle validate
ok: project `onboarding-node` with 2 resource(s)
Then boot:
$ lightshuttle up
The first up builds the image, so it takes a little longer. You will
see the database come up, then the service:
project `onboarding-node`: starting 2 resource(s)
db: starting
db: healthy
api: building
api: starting
api: running
LightShuttle dashboard ready at http://127.0.0.1:54321/
up stays in the foreground supervising the stack until you press
Ctrl+C. Leave it running and open a second terminal for the next step.
Step 6: Observe
List what is running:
$ lightshuttle ps
NAME KIND STATUS READY IMAGE
db postgres running yes postgres:16-alpine
api dockerfile running yes onboarding-node-api
Call the service:
$ curl http://localhost:8080/
{"db":"ok","now":"2026-06-12T09:41:08.512Z"}
The now value comes straight from Postgres: the request reached your
Node service, which queried the database and serialised the answer.
Stream its logs to confirm:
$ lightshuttle logs api
api listening on 8080
Add --follow (or -f) to keep tailing.
Step 7: Visit the dashboard
The boot log printed a dashboard URL
(http://127.0.0.1:54321/ above; your port will differ). Open it in a
browser. The index lists both resources with a live status that
refreshes every two seconds, and each row links to a detail page with a
streaming log pane.
For a full tour of every dashboard page, see the dashboard walkthrough.
Step 8: Shut down
Back in the first terminal, press Ctrl+C. LightShuttle stops the
resources in reverse order, giving each container ten seconds to exit
cleanly. If anything is left over, run:
$ lightshuttle down
stopped: api
stopped: db
down is idempotent: a second run prints
nothing to stop for project onboarding-node.
What’s next
- Add a secret with
${env.<NAME>}and a.envfile, as shown in Step 7 of getting started. - Try the same exercise in another stack: Python, Go or Rust.
- Generate deployment artifacts from this manifest with the export tutorial.
- Read the manifest specification for every supported field.
Onboarding: Python
This tutorial takes about fifteen minutes. You will build a small
Python HTTP service that queries Postgres, then boot it next to a
database with a single lightshuttle up. You do not need Python
installed locally: LightShuttle builds the service inside a container
from the Dockerfile you write.
If you have not installed the CLI yet, do Step 1 of getting started first, then come back.
Step 1: Scaffold the project
Create an empty directory and move into it:
$ mkdir onboarding-python && cd onboarding-python
By the end you will have four files in it:
onboarding-python/
app.py the HTTP service
requirements.txt its single dependency
Dockerfile how LightShuttle builds it
lightshuttle.yml the stack: Postgres + the service
Step 2: Write the service
The service reads the connection string from DATABASE_URL, runs one
query on each request, and answers with JSON. Create app.py:
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import psycopg
DATABASE_URL = os.environ["DATABASE_URL"]
PORT = int(os.environ.get("PORT", "8080"))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/":
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found\n")
return
try:
with psycopg.connect(DATABASE_URL) as conn:
row = conn.execute("select now() as now").fetchone()
body = json.dumps({"db": "ok", "now": row[0].isoformat()})
self.send_response(200)
except Exception as error:
body = json.dumps({"db": "error", "message": str(error)})
self.send_response(500)
self.send_header("content-type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
if __name__ == "__main__":
print(f"api listening on {PORT}", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
Two things are worth noting:
DATABASE_URLis never hard-coded. LightShuttle injects it at boot, pointing at the database resource. The same code runs unchanged against any Postgres.psycopg.connectopens one connection per request rather than holding a pool at startup, so the service starts even if the database needs a moment to become reachable.row[0]is a Pythondatetime, hence the.isoformat()call before serialising.
Declare the one dependency in requirements.txt:
psycopg[binary]==3.2.*
Step 3: Write the Dockerfile
LightShuttle builds the service from this Dockerfile. A two-stage
layout keeps dependency installation cached separately from your source,
so editing app.py does not reinstall psycopg:
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS dev
COPY app.py ./
EXPOSE 8080
CMD ["python", "app.py"]
The manifest will select the dev stage explicitly through
target: dev. A real project would add a leaner release stage on top
of the same base; here one stage is enough.
Step 4: Write the manifest
Now tie the two resources together. Create lightshuttle.yml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: onboarding-python
resources:
db:
postgres:
version: "16"
api:
dockerfile:
context: .
target: dev
env:
DATABASE_URL: ${resources.db.url}
ports:
- 8080
What each part does:
dbis a Postgres 16 instance. LightShuttle expands it to the officialpostgres:16-alpineimage, generates a password and binds a persistent volume.apiis built from theDockerfilein the current directory (context: .), selecting thedevstage.env.DATABASE_URLis set to${resources.db.url}. That interpolation resolves at boot to the full Postgres URL ofdb, and it also makesapidepend ondb: the service will not start until the database is healthy. No explicitdepends_onis needed.ports: [8080]publishes the container port on your host so you can reach the service from a browser orcurl.
Step 5: Boot the stack
Validate first. This parses the manifest and resolves interpolations without touching Docker:
$ lightshuttle validate
ok: project `onboarding-python` with 2 resource(s)
Then boot:
$ lightshuttle up
The first up builds the image, so it takes a little longer. You will
see the database come up, then the service:
project `onboarding-python`: starting 2 resource(s)
db: starting
db: healthy
api: building
api: starting
api: running
LightShuttle dashboard ready at http://127.0.0.1:54321/
up stays in the foreground supervising the stack until you press
Ctrl+C. Leave it running and open a second terminal for the next step.
Step 6: Observe
List what is running:
$ lightshuttle ps
NAME KIND STATUS READY IMAGE
db postgres running yes postgres:16-alpine
api dockerfile running yes onboarding-python-api
Call the service:
$ curl http://localhost:8080/
{"db":"ok","now":"2026-06-12T09:41:08.512Z"}
The now value comes straight from Postgres: the request reached your
Python service, which queried the database and serialised the answer.
Stream its logs to confirm:
$ lightshuttle logs api
api listening on 8080
Add --follow (or -f) to keep tailing.
Step 7: Visit the dashboard
The boot log printed a dashboard URL
(http://127.0.0.1:54321/ above; your port will differ). Open it in a
browser. The index lists both resources with a live status that
refreshes every two seconds, and each row links to a detail page with a
streaming log pane.
For a full tour of every dashboard page, see the dashboard walkthrough.
Step 8: Shut down
Back in the first terminal, press Ctrl+C. LightShuttle stops the
resources in reverse order, giving each container ten seconds to exit
cleanly. If anything is left over, run:
$ lightshuttle down
stopped: api
stopped: db
down is idempotent: a second run prints
nothing to stop for project onboarding-python.
What’s next
- Add a secret with
${env.<NAME>}and a.envfile, as shown in Step 7 of getting started. - Try the same exercise in another stack: Node.js, Go or Rust.
- Generate deployment artifacts from this manifest with the export tutorial.
- Read the manifest specification for every supported field.
Onboarding: Go
This tutorial takes about fifteen minutes. You will build a small Go
HTTP service that queries Postgres, then boot it next to a database with
a single lightshuttle up. You do not need Go installed locally:
LightShuttle builds the service inside a container from the Dockerfile
you write.
If you have not installed the CLI yet, do Step 1 of getting started first, then come back.
Step 1: Scaffold the project
Create an empty directory and move into it:
$ mkdir onboarding-go && cd onboarding-go
By the end you will have four files in it:
onboarding-go/
main.go the HTTP service
go.mod its single dependency
Dockerfile how LightShuttle builds it
lightshuttle.yml the stack: Postgres + the service
Step 2: Write the service
The service reads the connection string from DATABASE_URL, runs one
query on each request, and answers with JSON. Create main.go:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer pool.Close()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("content-type", "application/json")
var now time.Time
if err := pool.QueryRow(r.Context(), "select now()").Scan(&now); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"db": "error", "message": err.Error()})
return
}
json.NewEncoder(w).Encode(map[string]any{"db": "ok", "now": now})
})
log.Printf("api listening on %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Two things are worth noting:
DATABASE_URLis never hard-coded. LightShuttle injects it at boot, pointing at the database resource. The same code runs unchanged against any Postgres.pgxpool.Newcreates a connection pool that connects lazily. The pool validates the DSN immediately but opens actual connections on the first query, so the service starts even if the database needs a moment to become reachable.
Declare the one dependency in go.mod:
module onboarding-go
go 1.23
require github.com/jackc/pgx/v5 v5.7.1
Step 3: Write the Dockerfile
LightShuttle builds the service from this Dockerfile. A two-stage
layout separates the compile step from the final image, keeping the
runtime image small:
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN go mod tidy && go build -o /bin/api .
FROM alpine:3.20 AS dev
COPY --from=build /bin/api /bin/api
EXPOSE 8080
CMD ["/bin/api"]
The build stage copies main.go before running go mod tidy. That
order matters: tidy reads the import paths in the source to figure out
which modules are actually used. Because this tutorial does not commit a
go.sum file, tidy (which has network access during the build)
resolves and locks the dependency before go build. A real project
would commit both go.mod and go.sum and replace go mod tidy with
go mod download.
The manifest selects the dev stage explicitly through target: dev.
A real project would add a leaner release stage on top of the same
build; here one stage is enough.
Step 4: Write the manifest
Now tie the two resources together. Create lightshuttle.yml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: onboarding-go
resources:
db:
postgres:
version: "16"
api:
dockerfile:
context: .
target: dev
env:
DATABASE_URL: ${resources.db.url}
ports:
- 8080
What each part does:
dbis a Postgres 16 instance. LightShuttle expands it to the officialpostgres:16-alpineimage, generates a password and binds a persistent volume.apiis built from theDockerfilein the current directory (context: .), selecting thedevstage.env.DATABASE_URLis set to${resources.db.url}. That interpolation resolves at boot to the full Postgres URL ofdb, and it also makesapidepend ondb: the service will not start until the database is healthy. No explicitdepends_onis needed.ports: [8080]publishes the container port on your host so you can reach the service from a browser orcurl.
Step 5: Boot the stack
Validate first. This parses the manifest and resolves interpolations without touching Docker:
$ lightshuttle validate
ok: project `onboarding-go` with 2 resource(s)
Then boot:
$ lightshuttle up
The first up compiles the Go binary inside the container, so it takes
a little longer. You will see the database come up, then the service:
project `onboarding-go`: starting 2 resource(s)
db: starting
db: healthy
api: building
api: starting
api: running
LightShuttle dashboard ready at http://127.0.0.1:54321/
up stays in the foreground supervising the stack until you press
Ctrl+C. Leave it running and open a second terminal for the next step.
Step 6: Observe
List what is running:
$ lightshuttle ps
NAME KIND STATUS READY IMAGE
db postgres running yes postgres:16-alpine
api dockerfile running yes onboarding-go-api
Call the service:
$ curl http://localhost:8080/
{"db":"ok","now":"2026-06-12T09:41:08.512306Z"}
The now value comes straight from Postgres: the request reached your
Go service, which queried the database and serialised the answer. Stream
its logs to confirm:
$ lightshuttle logs api
api listening on 8080
Add --follow (or -f) to keep tailing.
Step 7: Visit the dashboard
The boot log printed a dashboard URL
(http://127.0.0.1:54321/ above; your port will differ). Open it in a
browser. The index lists both resources with a live status that
refreshes every two seconds, and each row links to a detail page with a
streaming log pane.
For a full tour of every dashboard page, see the dashboard walkthrough.
Step 8: Shut down
Back in the first terminal, press Ctrl+C. LightShuttle stops the
resources in reverse order, giving each container ten seconds to exit
cleanly. If anything is left over, run:
$ lightshuttle down
stopped: api
stopped: db
down is idempotent: a second run prints
nothing to stop for project onboarding-go.
What’s next
- Add a secret with
${env.<NAME>}and a.envfile, as shown in Step 7 of getting started. - Try the same exercise in another stack: Node.js, Python or Rust.
- Generate deployment artifacts from this manifest with the export tutorial.
- Read the manifest specification for every supported field.
Onboarding: Rust
This tutorial takes about twenty minutes. You will build a small Rust
HTTP service with axum that queries Postgres, then boot it next to a
database with a single lightshuttle up. You do not need Rust installed
locally: LightShuttle builds the service inside a container from the
Dockerfile you write.
If you have not installed the CLI yet, do Step 1 of getting started first, then come back.
Step 1: Scaffold the project
Create an empty directory and move into it:
$ mkdir onboarding-rust && cd onboarding-rust
By the end you will have four files in it:
onboarding-rust/
Cargo.toml the crate manifest and dependencies
src/main.rs the HTTP service
Dockerfile how LightShuttle builds it
lightshuttle.yml the stack: Postgres + the service
Step 2: Write the service
The service reads the connection string from DATABASE_URL, opens a
Postgres connection on each request, and answers with JSON.
Create Cargo.toml:
[package]
name = "onboarding-rust"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tokio-postgres = "0.7"
serde_json = "1"
Then create src/main.rs:
use std::env;
use axum::{routing::get, Json, Router};
use serde_json::json;
use tokio_postgres::NoTls;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
async fn root() -> Json<serde_json::Value> {
match query_now().await {
Ok(now) => Json(json!({ "db": "ok", "now": now })),
Err(error) => Json(json!({ "db": "error", "message": error.to_string() })),
}
}
async fn query_now() -> Result<String, BoxError> {
let url = env::var("DATABASE_URL")?;
let (client, connection) = tokio_postgres::connect(&url, NoTls).await?;
tokio::spawn(async move {
let _ = connection.await;
});
let row = client.query_one("select now()::text as now", &[]).await?;
Ok(row.get("now"))
}
#[tokio::main]
async fn main() {
let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let app = Router::new().route("/", get(root));
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
.await
.unwrap();
println!("api listening on {port}");
axum::serve(listener, app).await.unwrap();
}
Two things are worth noting:
DATABASE_URLis never hard-coded. LightShuttle injects it at boot, pointing at the database resource.tokio_postgres::connectreturns a pair(client, connection): theconnectiondrives the protocol wire and must be polled to completion, so we hand it totokio::spawnas a background task. Theclientis then free to issue queries while the connection task runs independently. We useselect now()::textto fetch the timestamp as a plain string, avoiding any dependency on a date library such aschrono.- The
mainfunction readsPORTwith a fallback of8080, so the same binary runs locally or inside a container without changes.
Step 3: Write the Dockerfile
LightShuttle builds the service from this Dockerfile. The build uses
two stages: the full rust:1.83-slim image compiles a release binary,
then only that binary is copied into a minimal debian:bookworm-slim
image. Because tokio-postgres uses NoTls (plain TCP on the private
Docker network), no OpenSSL runtime is needed in the final image, keeping
it small and the dependency surface narrow:
FROM rust:1.83-slim AS build
WORKDIR /src
COPY Cargo.toml ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim AS dev
COPY --from=build /src/target/release/onboarding-rust /bin/api
EXPOSE 8080
CMD ["/bin/api"]
The manifest will select the dev stage through target: dev. A real
project would tag a separate release stage from the same build; here
one final stage is enough.
Step 4: Write the manifest
Now tie the two resources together. Create lightshuttle.yml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: onboarding-rust
resources:
db:
postgres:
version: "16"
api:
dockerfile:
context: .
target: dev
env:
DATABASE_URL: ${resources.db.url}
ports:
- 8080
What each part does:
dbis a Postgres 16 instance. LightShuttle expands it to the officialpostgres:16-alpineimage, generates a password and binds a persistent volume.apiis built from theDockerfilein the current directory (context: .), selecting thedevstage.env.DATABASE_URLis set to${resources.db.url}. That interpolation resolves at boot to the full Postgres URL ofdb, and it also makesapidepend ondb: the service will not start until the database is healthy. No explicitdepends_onis needed.ports: [8080]publishes the container port on your host so you can reach the service from a browser orcurl.
Step 5: Boot the stack
Validate first. This parses the manifest and resolves interpolations without touching Docker:
$ lightshuttle validate
ok: project `onboarding-rust` with 2 resource(s)
Then boot:
$ lightshuttle up
The first up builds the image. Because cargo build --release runs
inside the build stage, the Docker layer cache downloads and compiles
all crates on the first run, which takes noticeably longer than
interpreted stacks. Subsequent builds reuse the cache unless Cargo.toml
changes. You will see the database come up, then the service:
project `onboarding-rust`: starting 2 resource(s)
db: starting
db: healthy
api: building
api: starting
api: running
LightShuttle dashboard ready at http://127.0.0.1:54321/
up stays in the foreground supervising the stack until you press
Ctrl+C. Leave it running and open a second terminal for the next step.
Step 6: Observe
List what is running:
$ lightshuttle ps
NAME KIND STATUS READY IMAGE
db postgres running yes postgres:16-alpine
api dockerfile running yes onboarding-rust-api
Call the service:
$ curl http://localhost:8080/
{"db":"ok","now":"2026-06-12 09:41:08.512306+00"}
The now value comes straight from Postgres: the request reached your
Rust service, which opened a connection, issued select now()::text, and
serialised the answer as JSON. Stream its logs to confirm:
$ lightshuttle logs api
api listening on 8080
Add --follow (or -f) to keep tailing.
Step 7: Visit the dashboard
The boot log printed a dashboard URL
(http://127.0.0.1:54321/ above; your port will differ). Open it in a
browser. The index lists both resources with a live status that refreshes
every two seconds, and each row links to a detail page with a streaming
log pane.
For a full tour of every dashboard page, see the dashboard walkthrough.
Step 8: Shut down
Back in the first terminal, press Ctrl+C. LightShuttle stops the
resources in reverse order, giving each container ten seconds to exit
cleanly. If anything is left over, run:
$ lightshuttle down
stopped: api
stopped: db
down is idempotent: a second run prints
nothing to stop for project onboarding-rust.
What’s next
- Add a secret with
${env.<NAME>}and a.envfile, as shown in Step 7 of getting started. - Try the same exercise in another stack: Node.js, Python or Go.
- Generate deployment artifacts from this manifest with the export tutorial.
- Read the manifest specification for every supported field.
How-to guides
How-to guides are task-oriented: they assume you already know the basics and want to accomplish something specific. Unlike a tutorial, a how-to guide solves one real problem and does not stop to explain every concept along the way.
Available guides:
- Manage secrets and environment variables
- Wire dependencies and gate on readiness
- Reach one resource from another
- Collect traces and metrics locally
- Troubleshooting and FAQ
When something fails, Troubleshooting and FAQ matches the common errors to a cause and a remedy. For an end-to-end walkthrough of the most common tasks, the getting started tutorial covers them in order.
Manage secrets and environment variables
Your manifest is committed to version control; your secrets are not. This
guide shows how to feed credentials and other environment values into a
stack without ever writing them into lightshuttle.yml, how to audit what
a stack needs before it boots, and how to read the diagnostics when
something is missing.
It assumes you have already booted a stack once. If you have not, start
with the getting started tutorial, which
introduces secrets at the end. For the exhaustive command surface, see the
secrets CLI reference.
The one rule to remember: a ${env.<NAME>} reference resolves from two
sources, in this order of precedence.
- A dotenv file (
.envin the working directory by default). - The process environment.
When both define the same name, the dotenv file wins. A value set to an empty string counts as unset.
The examples below use the runnable
examples/05-secrets
project. Clone it to follow along, or copy the manifest into a fresh
directory.
Require a secret
Use ${env.<NAME>} with no default for a value the stack cannot run
without. If it resolves to nothing, lightshuttle up refuses to boot and
names the variable, so a misconfigured stack fails fast instead of
half-starting.
# yaml-language-server: $schema=https://raw.githubusercontent.com/nubster-opensources/lightshuttle/main/docs/spec/manifest-v0.schema.json
project:
name: secrets-demo
description: "Secrets from a .env file: required and optional references"
resources:
db:
postgres:
version: "16"
# Required: `up` refuses to boot while DEMO_DB_PASSWORD is unset.
password: ${env.DEMO_DB_PASSWORD}
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo token=$API_TOKEN && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
# Optional: falls back to `dev-token` when unset.
API_TOKEN: ${env.DEMO_API_TOKEN:-dev-token}
Provide the value through a .env file next to the manifest, and make sure
that file is ignored by git:
$ echo 'DEMO_DB_PASSWORD=local-dev-password' > .env
$ echo '.env' >> .gitignore
Boot as usual; DEMO_DB_PASSWORD is now injected into the Postgres
resource:
$ lightshuttle up
Make a secret optional
Append :-<default> to give a reference a fallback. The form
${env.DEMO_API_TOKEN:-dev-token} resolves to dev-token whenever the
variable is unset or empty, so the stack always boots, and a developer can
override it locally without touching the manifest:
env:
API_TOKEN: ${env.DEMO_API_TOKEN:-dev-token}
Precedence still applies: a value in the .env file overrides the default,
and a value in the .env file also overrides the same name in the process
environment.
To emit a literal ${...} instead of interpolating it, double the braces:
${{not.interpolated}} renders the string ${not.interpolated} verbatim.
Audit before boot with secrets check
lightshuttle secrets check reports every ${env.*} reference the
manifest contains, with its status and source, without starting anything:
$ lightshuttle secrets check
secrets for project `secrets-demo`:
DEMO_DB_PASSWORD set (.env)
DEMO_API_TOKEN default (dev-token)
all required secrets are set
The status column tells you exactly where each value comes from:
| Status | Meaning |
|---|---|
set (.env) | resolved from the dotenv file |
set (env) | resolved from the process environment |
default (...) | unset, falling back to the declared default |
missing | unset, and at least one reference has no default |
When at least one variable is missing, the command exits non-zero.
validatedoes not check secrets.lightshuttle validateparses the manifest, resolves${resources.*}references and checks the dependency graph, but it deliberately does not resolve${env.*}values. Usesecrets checkto audit secrets, and rely on the fail-fast preflight ofupas the final guard. The two read from the same engine, sosecrets checkpredicts whatupwill accept.
Point at another .env file
Both up and secrets check accept --env-file <path> to read from a
file other than .env. This is how you keep one set of values per
environment, for example a .env.ci checked against in a pipeline:
$ lightshuttle secrets check --env-file .env.ci
The implicit .env is loaded only when it exists and is silently skipped
when absent. A file passed with --env-file is explicit, so it must
exist; the command errors if it does not.
Diagnose a failed boot
When up aborts at the preflight, it lists every required variable that
resolved to nothing. Reproduce the same diagnosis without a Docker daemon
by running secrets check, which uses the identical resolution engine:
$ rm .env
$ lightshuttle secrets check
secrets for project `secrets-demo`:
DEMO_DB_PASSWORD missing
DEMO_API_TOKEN default (dev-token)
DEMO_DB_PASSWORD is missing and the command exits non-zero. Restore the
value (in .env, in the process environment, or via --env-file) and the
check passes again. Because check needs no container runtime, it is the
fastest way to confirm a fix before re-running up.
Spot a divergent default
The same variable can be referenced in several places. When two references
declare different defaults, that is almost always a mistake: the value
your stack uses then depends on which resource reads it first. secrets check surfaces this by listing every distinct default it saw, sorted and
joined with |:
project:
name: divergent-demo
resources:
app:
container:
image: alpine:3.20
env:
LOG_A: ${env.LOG_LEVEL:-info}
LOG_B: ${env.LOG_LEVEL:-debug}
$ lightshuttle secrets check
secrets for project `divergent-demo`:
LOG_LEVEL default (debug | info)
all required secrets are set
A single default in the parentheses is normal. Two or more is a signal:
pick one value and make every reference agree, or set LOG_LEVEL
explicitly so the defaults no longer matter.
Gate a CI pipeline on secrets
Because secrets check exits non-zero when a required variable is missing,
it doubles as a cheap pipeline gate. Run it against the environment file
the pipeline provides, and the job fails before any container starts:
$ lightshuttle secrets check --env-file .env.ci
Pair it with lightshuttle validate --strict to catch both structural
manifest errors and missing secrets in the same stage.
Wire dependencies and gate on readiness
LightShuttle starts resources in dependency order and waits for each one to be ready before starting the resources that need it. This guide shows how to declare those dependencies, how readiness is decided, and how to tighten it with a custom healthcheck.
It assumes you have booted a stack before; if not, start with the
getting started tutorial. For the field
grammar, see the container and
Common types reference pages.
Declare a dependency implicitly
Most dependencies are declared for free: any ${resources.<name>.*}
interpolation creates a dependency on <name>. Reading the database URL
into the application’s environment is enough to make app start after
db:
project:
name: web-and-db
resources:
db:
postgres:
version: "16"
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo connected to $DATABASE_URL && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
# Custom healthcheck: dependents wait until this command succeeds.
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/ready"]
interval: "1s"
timeout: "1s"
retries: 10
start_period: "2s"
app will not start until db is healthy, and the interpolation also
gives app the resolved value of DATABASE_URL at boot.
Declare a dependency explicitly
When you need ordering but no value flows between the resources, use
depends_on. It accepts a list of resource names and is available on every
resource kind:
depends_on:
- db
- cache
Explicit and implicit dependencies are merged and de-duplicated, so adding
depends_on: [db] next to a ${resources.db.url} reference is harmless.
Understand how readiness is decided
A resource is ready, and therefore unblocks its dependents, when:
- its healthcheck succeeds, if one is defined; or
- the container reports a
runningstate, if no healthcheck is defined.
The managed resource kinds (postgres, redis) ship with a built-in
healthcheck, so a dependent waits for a real connection, not just a started
process. Independent resources start in parallel; only dependency edges
force serialisation.
Add a custom healthcheck
For a plain container, “running” is often too weak: the process is up but
not yet serving. Add a healthcheck so dependents wait for genuine
readiness. The fields mirror Docker Compose:
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
interval: "5s"
timeout: "3s"
retries: 5
start_period: "5s"
testis required; its first element should beCMD(exec form) orCMD-SHELL(run through a shell).interval,timeoutandstart_periodare Go duration strings ("5s","500ms","2m"); they default to5s,3sand5s.retriesis the number of consecutive failures before the resource is marked unhealthy; it defaults to5.start_periodis a grace window after start during which failures are not counted, so a slow boot does not trip the check.
Let validate catch wiring mistakes
lightshuttle validate checks the dependency graph without touching
Docker. Two mistakes are hard errors at validate time:
- a dependency on a resource that does not exist; and
- a dependency cycle, with every resource in the cycle named in the message.
Run it in CI with --strict to fail the build on either:
$ lightshuttle validate --strict
Shutdown follows the reverse order: dependents stop before the resources they relied on.
For the reasoning behind this ordering, readiness gating and the shutdown grace window, see The resource lifecycle.
Reach one resource from another
Every LightShuttle project runs on its own network where containers find each other by name. This guide shows how that network works, how to address a resource, and the two ways a container can discover its dependencies.
For the reasoning behind the per-project network and the two wiring styles, see Networking and service discovery. For the underlying rules, see the Networking section of the manifest specification.
Address a resource by its name
At up time LightShuttle creates a dedicated Docker bridge network named
lightshuttle-<project> and removes it at down. Every container joins it
with a DNS alias equal to its resource name, so containers reach each other
by that name. The host property of a resource resolves to exactly this
alias:
project:
name: discovery-demo
resources:
db:
postgres:
version: "16"
cache:
redis:
version: "7"
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo db=$DB_HOST cache=$CACHE_URL && sleep 3600"]
env:
DB_HOST: ${resources.db.host}
CACHE_URL: ${resources.cache.url}
DB_HOST becomes db, the resource name, which is the hostname app
uses to open a connection on the project network.
Rely on project isolation
Two projects never collide: each sits on its own
lightshuttle-<project> network, so a resource named db in one project
is unreachable from another. You can run several stacks side by side
without port or name clashes. The project name is lowercased and any
character outside letters, digits and hyphens is replaced by - when the
network name is built.
Discover a dependency with zero configuration
For every dependency, LightShuttle also injects each property of that
dependency as an environment variable named LSH_<DEP>_<PROPERTY>,
upper-cased. With db as a dependency, the container receives:
| Variable | Resolves to |
|---|---|
LSH_DB_HOST | ${resources.db.host} |
LSH_DB_PORT | ${resources.db.port} |
LSH_DB_DATABASE | ${resources.db.database} |
LSH_DB_USER | ${resources.db.user} |
LSH_DB_PASSWORD | ${resources.db.password} |
LSH_DB_URL | ${resources.db.url} |
The same pattern applies to every dependency: LSH_CACHE_HOST,
LSH_CACHE_URL, and so on.
Choose explicit or automatic wiring
The two styles coexist on purpose:
- Explicit interpolation (
DATABASE_URL: ${resources.db.url}) keeps your application portable. It reads the standardDATABASE_URLthat every language ecosystem already understands, with no LightShuttle knowledge baked in. - Automatic
LSH_<DEP>_<PROP>variables are a zero-configuration escape hatch for wiring a quick script without editing the manifest.
Prefer explicit interpolation for application code you might run outside
LightShuttle; reach for the LSH_* variables for throwaway glue.
Collect traces and metrics locally
LightShuttle ships a bundled OpenTelemetry collector and wires your containers to it automatically, so you can see traces and metrics without standing up an observability stack by hand. This guide shows how to control the collector, what it injects, and where to read metrics.
For the field grammar, see the
observability and
dashboard reference pages. For a
tour of the web dashboard itself, see the
dashboard walkthrough.
Enable the bundled collector
The collector is on by default. The observability.otel block makes the
choice explicit and is where you turn it off:
project:
name: observability-demo
dashboard:
port: 7878
observability:
otel:
enabled: true
resources:
db:
postgres:
version: "16"
app:
container:
image: alpine:3.20
command: ["sh", "-c", "echo OTLP=$OTEL_EXPORTER_OTLP_ENDPOINT && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
On up, LightShuttle prepends a collector container
(otel/opentelemetry-collector:0.108.0) to the stack and makes every other
container depend on it, so the collector is ready before your services
start emitting.
Know what gets injected
Into every container and dockerfile resource, LightShuttle adds three
standard OpenTelemetry variables:
| Variable | Value |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | http://<project>_lightshuttle_otel:4317 |
OTEL_SERVICE_NAME | the resource name |
OTEL_RESOURCE_ATTRIBUTES | service.name=<resource>,deployment.environment=local |
The collector receives OTLP on 4317 (gRPC) and 4318 (HTTP). Managed
kinds (postgres, redis) use canned commands and are not injected.
Injection never overwrites a value you set yourself: if your manifest
already defines OTEL_SERVICE_NAME, LightShuttle keeps yours. The collector
is also a normal resource named lightshuttle_otel, so a service can target
it directly with ${resources.lightshuttle_otel.host} when needed.
Turn it off
Skip the collector and the env injection entirely either per manifest or per run:
observability:
otel:
enabled: false
$ lightshuttle up --no-otel
Use the manifest form when a project never wants the collector, and the flag for a one-off run.
Override the collector image
To pin a different collector build or configuration, declare a resource
with the reserved name lightshuttle_otel yourself. LightShuttle detects
it, skips its own augmentation and leaves your definition in place:
lightshuttle_otel:
container:
image: otel/opentelemetry-collector-contrib:0.108.0
Read metrics from the dashboard
The local control plane exposes a Prometheus endpoint at /metrics in the
standard text exposition format, alongside the web dashboard. Pin the
dashboard to a known port to scrape it:
dashboard:
port: 7878
With the stack up, http://localhost:7878/metrics returns the current
metrics; point a local Prometheus or curl at it. Without a port,
lightshuttle up picks a free one and prints it at startup.
Troubleshooting and FAQ
When lightshuttle up (or validate, or a build) fails, the message
usually tells you which of a handful of common situations you are in.
This page lists those situations in a fixed symptom, cause, remedy
shape so you can match the error you see and move on quickly. A short
FAQ at the end answers recurring questions.
Each entry has a stable anchor (for example
#docker-daemon-unreachable) so error
messages and other docs can link straight to it.
Exit codes
Before the individual cases, the exit code already narrows things down:
| Code | Meaning |
|---|---|
0 | Success: the stack started and shut down cleanly. |
1 | User error: invalid manifest, missing file, failed validation, or a resource that ended in a failed state. |
2 | Runtime error: the container runtime is unreachable or a container failed to start or build. |
130 | Interrupted with Ctrl+C (SIGINT). |
A 1 points at your manifest or environment. A 2 points at Docker or
a container.
Docker daemon unreachable
Symptom. Any command that touches containers fails immediately:
Error: failed to connect to the container runtime
followed by a lower-level message from the Docker client (for example
error trying to connect: ... The system cannot find the file specified
on Windows, or Cannot connect to the Docker daemon at unix:///var/run/docker.sock on Linux). The exit code is 2.
Cause. LightShuttle talks to Docker over its local socket through
Docker::connect_with_local_defaults. The daemon is not running, or
your user cannot reach the socket.
Remedy. Start Docker and confirm it answers before retrying:
$ docker version --format '{{.Server.Version}}'
27.3.1
On Linux, if the daemon is up but the socket is denied, add your user to
the docker group (and start a new session), or use colima/rootless
Docker. Then run lightshuttle up again.
Port already in use
Symptom. The database or a published service container fails to start, and the underlying Docker error mentions an address already in use, for example:
Bind for 0.0.0.0:8080 failed: port is already allocated
The exit code is 2.
Cause. A ports entry maps a container port onto a host port that
another process (often a previous run that did not shut down, or an
unrelated service) already holds.
Remedy. Free the port or publish on a different host port. List what
holds it (lsof -i :8080 on macOS/Linux,
Get-NetTCPConnection -LocalPort 8080 on Windows). If it is a stale
LightShuttle run, clear it with lightshuttle down. To move the host
port, set the mapping explicitly in the manifest ("8081:8080" publishes
container 8080 on host 8081). Postgres and Redis are reached by other
containers over the private network by name, so you rarely need to
publish them on the host at all.
A resource never becomes healthy
Symptom. Boot stalls on one resource and eventually fails with a timeout, for example:
db: starting
Error: timed out waiting for `db` to become healthy
Cause. The resource started but its healthcheck never passed inside
the allotted time. Common reasons: the database is still initialising on
a slow first run, the container is crash-looping, or a custom
healthcheck probes the wrong command or port.
Remedy. Look at the resource’s own logs first:
$ lightshuttle logs db
If it is simply slow to initialise, raise the healthcheck budget on the
resource (healthcheck.timeout and healthcheck.retries; see the
manifest specification). If you wrote a custom healthcheck,
verify the test command succeeds inside the container. A healthcheck
whose test is empty is rejected earlier at validation with
healthcheck.test cannot be empty.
Missing required secret
Symptom. up refuses to boot:
Error: missing required environment variable(s): API_TOKEN
The audit command reports the same condition with an actionable hint:
$ lightshuttle secrets check
...
1 required variable(s) not set: API_TOKEN (add them to a .env file or pass --env-file <PATH>)
The exit code is 1.
Cause. The manifest references ${env.NAME} with no default, and
NAME is set neither in a .env file next to the manifest nor in the
process environment. LightShuttle fails fast rather than booting a
half-configured stack.
Remedy. Add the variable to .env (and to .gitignore), or pass
--env-file <path>. Make the reference optional with a default if the
stack can run without it: ${env.NAME:-fallback}. Use
lightshuttle secrets check as a cheap pre-boot or CI gate. The full
workflow is covered in
Manage secrets and environment variables.
Image build fails (BuildKit)
Symptom. A dockerfile resource fails during its build step:
api: building
Error: image build failed: <output from the failing build step>
You may also see failed to build image from Dockerfile (the build
could not be started at all) or failed to build tar archive: ... (the
build context could not be packaged). The exit code is 2.
Cause. The build context or Dockerfile is wrong: a missing file in
context, a failing RUN step, an unreachable base image, or a
target that does not name a real stage.
Remedy. Reproduce the build directly to get BuildKit’s full output:
$ docker build --target dev .
Fix what docker build reports, then retry lightshuttle up. Check
that context points at the directory holding the files the Dockerfile
copies, and that target matches a stage name (AS <name>) actually
declared in the file.
Dependency cycle rejected
Symptom. validate (and therefore up) refuses the manifest:
Error: dependency cycle detected: a -> b -> a
The exit code is 1.
Cause. Two or more resources depend on each other, directly or
transitively, so there is no order in which they can start. The
dependency can be an explicit depends_on or an implicit one created by
an ${resources.*} interpolation. For example this is a cycle:
resources:
a:
container:
image: alpine:3.20
depends_on: [b]
b:
container:
image: alpine:3.20
depends_on: [a]
Remedy. Break the cycle. One of the two resources almost always does not really need the other at startup: drop that edge, or introduce a third resource both depend on. LightShuttle starts resources in dependency order, so the graph must be acyclic.
Unknown resource reference
Symptom. validate rejects the manifest with:
Error: unknown resource reference: `cache` (depended on by `api`)
The exit code is 1.
Cause. A depends_on entry or an ${resources.NAME.*} interpolation
names a resource that does not exist, usually a typo or a renamed
resource.
Remedy. Make the name match a key under resources exactly. Resource
names are case sensitive.
OpenTelemetry collector issues
Symptom. Traces or metrics never reach your collector, but the stack otherwise runs. At startup you may see a warning rather than an error:
OTel tracer init failed; continuing without self-tracing
and on shutdown, occasionally:
lightshuttle-otel: tracer flush on shutdown failed: <error>
Cause. Self-tracing is best effort by design. If the OTLP exporter
cannot be built or the collector is unreachable, LightShuttle logs a
warning and keeps running: it never fails up over telemetry, and spans
emitted while the collector is down are dropped silently. The collector
listens on loopback only, OTLP gRPC on 127.0.0.1:4317 and OTLP HTTP on
127.0.0.1:4318.
Remedy. Confirm the collector resource is healthy
(lightshuttle ps) and that your application points at the right
endpoint (4317 for gRPC, 4318 for HTTP, on 127.0.0.1). Because the
collector carries no healthcheck, a crash shows only as a stopped
container, so check its logs with lightshuttle logs <collector>. The
end-to-end setup is covered in
Collect traces and metrics locally.
FAQ
Do I need to know Rust to use LightShuttle?
No. The CLI is distributed as a binary and your services run in
containers built from your own Dockerfile. The
onboarding tutorials show the same
workflow for Node.js, Python, Go and Rust.
Is this a docker-compose replacement?
No. LightShuttle is the local stack runner you reach for while coding;
it is not a production runtime, a Kubernetes replacement or a service
mesh. When you ship, lightshuttle export
turns the same manifest into Compose, Kubernetes manifests or a Helm
chart.
Can I run two projects at the same time?
Yes. Each project runs on its own Docker network named after
project.name, so stacks stay isolated and do not collide. See
Reach one resource from another.
Where does my Postgres data live between runs?
A Postgres resource binds an auto-named persistent volume, so data
survives down and a later up. Remove the volume with the Docker CLI
if you want a clean database.
Why is the dashboard on a different port every time?
up picks a random free loopback port by default and prints it. Pin it
by setting dashboard.port in the manifest. See the
dashboard walkthrough.
Can I use LightShuttle in production?
No. Use it for local development, then generate deployment artifacts with
lightshuttle export and run those on your
production platform.
Does it work on Windows? Yes. Docker Desktop on Windows works out of the box. Examples in the docs use a POSIX shell; PowerShell equivalents are noted where they differ.
Reference
Reference material is information-oriented: exhaustive and normative. When you need to know exactly what a field means or what a command does, this is the section to consult.
Specifications
The normative specifications live in the repository and are versioned alongside the manifest:
- Manifest specification: every section, resource kind and interpolation rule.
- Control plane API: the local HTTP API the dashboard and the client commands speak.
- Observability: the bundled OpenTelemetry collector and the metrics it exposes.
- Export: how the manifest maps to each deployment target.
A JSON Schema for editor autocompletion ships at
docs/spec/manifest-v0.schema.json.
API documentation
The Rust API of every published crate is documented on docs.rs:
lightshuttle(the CLI)lightshuttle-manifestlightshuttle-speclightshuttle-runtimelightshuttle-otellightshuttle-controllightshuttle-secretslightshuttle-export
The manifest reference is generated from the JSON Schema and documents every top-level section and resource kind. The CLI reference is generated from the command definitions and documents every subcommand, its flags and examples.
Manifest reference
These pages are generated from the JSON Schema (docs/spec/manifest-v0.schema.json) by cargo xtask doc-gen manifest. Do not edit them by hand.
Top-level sections
- project (required)
resources(required): a map of resource names to one resource kind below- dashboard
- observability
- export
Resource kinds
Each resources entry selects exactly one kind:
Shared types
project
Project metadata, corresponding to the project: section of the manifest.
The name field must match the pattern ^[a-z][a-z0-9_-]{0,31}$ and is
validated by [Manifest::validate]. It is used by the runtime as a prefix
for container and network names, and by lightshuttle-export as the
default Helm chart name and Kubernetes namespace.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
description | string | no | Free-form description displayed in the local dashboard. | |
name | string | yes | Project name. Must match ^[a-z][a-z0-9_-]{0,31}$. Used as a prefix for all runtime resource names (containers, networks, volumes) so it must be stable across machines. | |
version | string | no | Free-form version label. Informational only; not validated. |
dashboard
Settings for the local control-plane HTTP server (the dashboard).
Stored in [crate::Manifest::dashboard]. When the whole dashboard: section
is absent from the manifest, the runtime uses its built-in defaults
(random free port, all-loopback binding).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
port | integer | no | Fixed TCP port for the dashboard HTTP server. - Absent or null: the runtime picks a random free port at startup. - 0: rejected by [crate::Manifest::validate] with [crate::ManifestError::InvalidDashboardPort] (indistinguishable from “no preference” at the OS level). - 1..=65535: the dashboard binds to this port. |
observability
Top-level observability settings, corresponding to the observability:
section in lightshuttle.yml.
Currently only controls the bundled OpenTelemetry collector via
[OtelConfig]. More toggles (tracing, profiling) may be added in
future specification revisions.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
otel | OtelConfig | no | OpenTelemetry collector configuration. None preserves the default-on behaviour (the bundled collector starts alongside the project resources). |
export
Top-level export settings, one optional sub-table per export target.
Stored in [crate::Manifest::export]. The section is purely structural: it
carries raw optional values only. Defaults such as the chart name, the
Kubernetes namespace, and replica counts are resolved during the lowering
step in lightshuttle-export, so this crate never owns export semantics.
All resource keys in sub-tables are validated against the manifest’s
declared resources by [crate::Manifest::validate].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
compose | ComposeExport | no | Per-resource overrides for the docker-compose export target. | |
helm | HelmExport | no | Per-resource overrides for the Helm chart target. | |
kubernetes | KubernetesExport | no | Per-resource overrides for the raw Kubernetes manifests target. |
postgres
Configuration of a managed PostgreSQL instance.
The runtime resolves the effective image using the following priority
order: image (if set) takes precedence; otherwise version is
expanded to postgres:<version>-alpine; if neither is set the
runtime picks its own default.
The database field must match ^[a-z][a-z0-9_]{0,62}$ and is
validated by [crate::Manifest::validate].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
database | string | no | Initial database name created at first startup. Must match ^[a-z][a-z0-9_]{0,62}$. Defaults to the resource name when unset. | |
depends_on | array of string | no | Names of other resources this instance must wait for before starting. | |
healthcheck | Healthcheck | no | Healthcheck override. Replaces the built-in pg_isready check. See [Healthcheck] for field semantics and defaults. | |
image | string | no | Explicit image reference. Takes precedence over version. Use this to pin a specific digest or to point to a private registry. | |
password | string | no | Superuser password. The runtime generates a random password when this is unset and exposes it via ${resources.name.password}. | |
port | integer | no | Host port the container port 5432 is mapped to. The runtime chooses a random free port when unset. | |
user | string | no | Superuser account name. Defaults to "postgres" when unset. | |
version | string | no | PostgreSQL major version, e.g. "16". Expanded into postgres:<version>-alpine when image is absent. Ignored when image is set. | |
volume | Volume | no | Persistent volume configuration. See [Volume] for the accepted forms. Defaults to an auto-named volume when unset. |
redis
Configuration of a managed Redis instance.
The runtime resolves the effective image using the same priority as
[crate::PostgresConfig]: image takes precedence; otherwise version is
expanded to redis:<version>-alpine.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
depends_on | array of string | no | Names of other resources this instance must wait for before starting. | |
healthcheck | Healthcheck | no | Healthcheck override. Replaces the built-in redis-cli PING check. See [Healthcheck] for field semantics and defaults. | |
image | string | no | Explicit image reference. Takes precedence over version. | |
password | string | no | Authentication password for the requirepass directive. An empty string or None runs Redis without authentication. | |
port | integer | no | Host port the container port 6379 is mapped to. The runtime chooses a random free port when unset. | |
version | string | no | Redis major version, e.g. "7". Expanded into redis:<version>-alpine when image is absent. | |
volume | Volume | no | Persistent volume configuration. See [Volume] for accepted forms. Defaults to an auto-named volume when unset. |
container
Configuration of a container resource backed by a registry image.
Corresponds to the container: key in a resource entry. The runtime
pulls image, applies the declared port mappings, mounts volumes,
and injects environment variables before starting the container.
See [crate::DockerfileConfig] for the locally-built equivalent.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
command | Command | no | Optional entrypoint override. See [Command] for the two accepted forms (string or argument list). | |
depends_on | array of string | no | Names of other resources this container must wait for before starting. Validated by [crate::Manifest::validate]. | |
env | map of string | no | Environment variables injected into the container at startup. Values are interpolated: ${env.NAME} and ${resources.name.property} expressions are resolved at runtime. | |
healthcheck | Healthcheck | no | Optional healthcheck. Overrides whatever is baked into the image. See [Healthcheck] for field semantics and defaults. | |
image | string | yes | Full image reference including the tag, e.g. "nginx:1.25-alpine". | |
ports | array of PortMapping | no | Port mappings between the host and the container. Each element is a [PortMapping]: either a bare container port (mirrored on the host) or a full "host:container" string. | |
volumes | array of string | no | Volume mappings in "host:container" or "named:container" form. Relative host paths (starting with .) are resolved against the manifest directory by [crate::Manifest::resolve_host_volume_paths] before they reach the runtime. | |
working_dir | string | no | Optional working directory override inside the container. |
dockerfile
Configuration of a dockerfile resource built locally before being run.
The runtime performs a docker build in context, then starts the
resulting image as it would for a [crate::ContainerConfig].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
build_args | map of string | no | Build-time ARG values passed to docker build --build-arg. | |
command | Command | no | Optional entrypoint override. See [Command] for accepted forms. | |
context | string | yes | Build context path, relative to the manifest file. Resolved to an absolute path by [crate::Manifest::resolve_host_volume_paths] before it is handed to the runtime. | |
depends_on | array of string | no | Names of other resources this build must wait for before starting. Validated by [crate::Manifest::validate]. | |
dockerfile | string | no | "Dockerfile" | Path to the Dockerfile within context. Defaults to "Dockerfile". |
env | map of string | no | Environment variables injected into the container at runtime. Values support ${env.NAME} and ${resources.name.property} interpolation. | |
healthcheck | Healthcheck | no | Optional healthcheck override. See [Healthcheck] for field semantics. | |
ports | array of PortMapping | no | Port mappings between the host and the container. See [PortMapping]. | |
target | string | no | Multi-stage build target passed to docker build --target. | |
volumes | array of string | no | Volume mappings in "host:container" or "named:container" form. Relative host paths are resolved by [crate::Manifest::resolve_host_volume_paths]. | |
working_dir | string | no | Optional working directory override inside the container. |
Common types
Shared configuration types referenced from the manifest sections and resource kinds above.
Command
Container entrypoint override.
Two forms are accepted in the manifest YAML:
- A single string is interpreted shell-style, equivalent to wrapping
the value in
sh -c "...". Convenient for one-liners. - An array of strings is passed directly to the container runtime as an argument vector, giving precise control over quoting and whitespace.
Used in the command field of [crate::ContainerConfig] and
[crate::DockerfileConfig].
ComposeExport
Global overrides for the docker-compose export target.
Nested under [ExportConfig::compose]. All resource keys in resources
must reference a name declared in [crate::Manifest::resources], enforced by
[crate::Manifest::validate].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
resources | map of ComposeResourceExport | no | Per-resource overrides, keyed by manifest resource name. Resources not listed here receive the default behaviour (included, standard image naming). |
ComposeResourceExport
Per-resource overrides for the docker-compose export target.
Used as the value type in [ComposeExport::resources].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | no | Whether this resource is included in the export. None or Some(true) includes the resource. Some(false) omits it. |
Healthcheck
Per-resource healthcheck configuration.
Field semantics mirror Docker Compose so that existing knowledge
transfers directly. Duration fields (interval, timeout,
start_period) accept strings like "5s", "200ms", or "2m" and
are validated by [crate::Manifest::validate].
A Healthcheck is embedded in [crate::PostgresConfig], [crate::RedisConfig],
[crate::ContainerConfig], and [crate::DockerfileConfig] via their healthcheck
field, and is also accessible through [crate::ResourceKind::healthcheck].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
interval | string | no | "5s" | Time between consecutive check executions. Default "5s". Accepted suffixes: ns, us, ms, s, m, h. |
retries | integer | no | 5 | Number of consecutive failures needed to declare the resource unhealthy. Default 5. |
start_period | string | no | "5s" | Grace period at startup during which check failures are not counted toward retries. Default "5s". |
test | array of string | yes | Command to run. The first element must be "CMD" or "CMD-SHELL". Cannot be empty (enforced by [crate::Manifest::validate]). | |
timeout | string | no | "3s" | Maximum duration a single check execution may take before the runtime treats it as failed. Default "3s". |
HelmExport
Global overrides for the Helm chart export target.
Nested under [ExportConfig::helm]. The chart name and version default
to the project name and version respectively, resolved by
lightshuttle-export during lowering.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
chart_name | string | no | Chart name. Defaults to the project name during lowering. | |
chart_version | string | no | Chart version (SemVer string). Defaults to the project version when set, otherwise "0.1.0". | |
replicas | integer | no | Default replica count exposed via the generated chart’s values.yaml. | |
resources | map of HelmResourceExport | no | Per-resource overrides, keyed by manifest resource name. |
HelmResourceExport
Per-resource overrides for the Helm chart export target.
Used as the value type in [HelmExport::resources].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | no | Whether this resource is included in the chart. None or Some(true) includes the resource. Some(false) omits it. | |
replicas | integer | no | Replica count override for this resource, taking precedence over [HelmExport::replicas]. |
ImagePullPolicy
Kubernetes image pull policy, used in [KubernetesExport] and
[KubernetesResourceExport].
Maps directly to the imagePullPolicy field in a Kubernetes
Container spec.
KubernetesExport
Global overrides for the raw Kubernetes manifests export target.
Nested under [ExportConfig::kubernetes]. Defaults (namespace, replica
count, pull policy) are resolved by lightshuttle-export during
lowering.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
image_pull_policy | ImagePullPolicy | no | Default image pull policy applied to every resource. See [ImagePullPolicy] for accepted values. Defaults to [ImagePullPolicy::IfNotPresent]. | |
namespace | string | no | Target Kubernetes namespace. Defaults to the project name when absent. | |
replicas | integer | no | Default replica count for every resource that supports it. | |
resources | map of KubernetesResourceExport | no | Per-resource overrides, keyed by manifest resource name. |
KubernetesResourceExport
Per-resource overrides for the Kubernetes manifests export target.
Used as the value type in [KubernetesExport::resources].
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | no | Whether this resource is included in the export. None or Some(true) includes the resource. Some(false) omits it. | |
image_pull_policy | ImagePullPolicy | no | Image pull policy override for this specific resource, taking precedence over [KubernetesExport::image_pull_policy]. | |
replicas | integer | no | Replica count override for this specific resource, taking precedence over [KubernetesExport::replicas]. |
OtelConfig
Toggle for the bundled OpenTelemetry collector.
Nested under [ObservabilityConfig::otel] in the manifest.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | no | Whether the bundled OpenTelemetry collector is active. - None or Some(true): the collector starts and OTLP environment variables are injected into every container. - Some(false): lightshuttle up skips the collector and does not inject any OTLP variables. |
PortMapping
Port mapping for a container resource.
Two forms are accepted in the manifest:
- Integer short form: the runtime mirrors the container port on an
identical host port. Example:
8080maps0.0.0.0:8080 -> 8080. - String full form: supports the
"host:container"and"bind_addr:host:container"syntaxes understood by the container runtime. Example:"127.0.0.1:9090:9090".
Used in the ports field of [crate::ContainerConfig] and
[crate::DockerfileConfig].
Volume
Volume persistence specification for a managed resource.
Three forms are accepted in the manifest:
true(default): the runtime provisions an auto-named volume.false: no volume; the container data directory is ephemeral and lost whenlightshuttle downremoves the container.- A string such as
"my-db-data": an explicitly named volume, shared across projects or preserved with a predictable name.
Used in the volume field of [crate::PostgresConfig] and [crate::RedisConfig].
CLI reference
These pages are generated from the clap command definitions by cargo xtask doc-gen cli. Do not edit them by hand.
lightshuttle: Lightweight dev orchestrator for polyglot teams
Global options
| Option | Description |
|---|---|
-f, --file <FILE> | Path to the manifest. Overrides the upward discovery |
Commands
| Command | Description |
|---|---|
| up | Boot the stack and supervise it until interrupted |
| down | Stop every container managed by this project |
| ps | List managed resources and their status |
| logs | Stream logs of a single resource |
| validate | Parse and validate the manifest without starting anything |
| manifest | Dump the resolved manifest to stdout as YAML |
| restart | Restart a single managed resource through the running control plane. Requires lightshuttle up to be active in the same working directory so the discovery file .lightshuttle/control.url is present |
| alias | Manage the optional lsh shell alias |
| export | Generate deployment artifacts from the manifest |
| secrets | Inspect ${env.*} variable references in the manifest |
up
Boot the stack and supervise it until interrupted
Usage
lightshuttle up [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--grace <GRACE> | 10s | SIGTERM-to-SIGKILL grace window per resource at shutdown |
--control-port <CONTROL_PORT> | Override the local control plane port. Defaults to dashboard.port from the manifest, or a random free port picked by the OS | |
--no-otel | Skip the bundled OpenTelemetry collector and the per-resource OTEL_* env injection, even if observability.otel.enabled is true (or absent) in the manifest | |
--env-file <ENV_FILE> | Path to a .env file supplying secret values referenced as ${env.VAR} in the manifest. An explicit path that does not exist is an error. The default .env is silently skipped when absent |
Examples
# Boot the stack defined by the nearest manifest
lightshuttle up
# Use an explicit manifest and a custom control plane port
lightshuttle -f stack.yaml up --control-port 8080
# Run without the bundled OpenTelemetry collector
lightshuttle up --no-otel
down
Stop every container managed by this project
Usage
lightshuttle down [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--grace <GRACE> | 10s | SIGTERM-to-SIGKILL grace window per container |
Examples
# Stop every container, allowing 30s for graceful shutdown
lightshuttle down --grace 30s
ps
List managed resources and their status
Usage
lightshuttle ps
Examples
# List managed resources and their status
lightshuttle ps
logs
Stream logs of a single resource
Usage
lightshuttle logs [OPTIONS] <RESOURCE>
Arguments
| Argument | Description |
|---|---|
<RESOURCE> | Resource name as declared in the manifest |
Options
| Option | Default | Description |
|---|---|---|
-f, --follow | Follow the log stream until interrupted |
Examples
# Show the recent logs of the `api` resource
lightshuttle logs api
# Follow the log stream until interrupted
lightshuttle logs api --follow
validate
Parse and validate the manifest without starting anything
Usage
lightshuttle validate [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--strict | Upgrade warnings to errors. Use in continuous integration |
Examples
# Validate the manifest
lightshuttle validate
# Fail on warnings, for continuous integration
lightshuttle validate --strict
manifest
Dump the resolved manifest to stdout as YAML
Usage
lightshuttle manifest
Examples
# Print the resolved manifest as YAML
lightshuttle manifest
restart
Restart a single managed resource through the running control plane. Requires lightshuttle up to be active in the same working directory so the discovery file .lightshuttle/control.url is present
Usage
lightshuttle restart [OPTIONS] <RESOURCE>
Arguments
| Argument | Description |
|---|---|
<RESOURCE> | Resource name as declared in the manifest |
Options
| Option | Default | Description |
|---|---|---|
--detach | Return immediately after the control plane accepted the restart request, without waiting for the resource to become healthy again |
Examples
# Restart the `api` resource and wait for it to become healthy again
lightshuttle restart api
# Request the restart and return immediately
lightshuttle restart api --detach
alias
Manage the optional lsh shell alias
Usage
lightshuttle alias <COMMAND>
Examples
# Install the `lsh` alias into your shell's startup file
lightshuttle alias install
# Preview what install would do, without writing anything
lightshuttle alias check
# Remove the alias
lightshuttle alias uninstall
Subcommands
install
Add the lsh alias to your shell’s startup file
Usage
lightshuttle alias install [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--shell <SHELL> | Override shell auto-detection [possible values: bash, zsh, fish, powershell] | |
--yes | Skip the confirmation prompt |
check
Report what install would do, without writing anything
Usage
lightshuttle alias check [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--shell <SHELL> | Override shell auto-detection [possible values: bash, zsh, fish, powershell] |
uninstall
Remove the lsh alias from your shell’s startup file
Usage
lightshuttle alias uninstall [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--shell <SHELL> | Override shell auto-detection [possible values: bash, zsh, fish, powershell] | |
--yes | Skip the confirmation prompt |
export
Generate deployment artifacts from the manifest
Usage
lightshuttle export [OPTIONS] <TARGET>
Arguments
| Argument | Description |
|---|---|
<TARGET> | Target format to generate [possible values: compose, kubernetes, helm] |
Options
| Option | Default | Description |
|---|---|---|
-o, --output <OUTPUT> | Output directory. Defaults to ./export/<target> | |
--force | Overwrite a non-empty output directory |
Examples
# Generate a docker-compose.yml under ./export/compose
lightshuttle export compose
# Generate Kubernetes manifests into a chosen directory, overwriting it
lightshuttle export kubernetes --output ./k8s --force
secrets
Inspect ${env.*} variable references in the manifest
Usage
lightshuttle secrets <COMMAND>
Examples
# Report which ${env.*} variables are set, defaulted, or missing
lightshuttle secrets check
# Check against a specific .env file
lightshuttle secrets check --env-file .env.prod
Subcommands
check
Report which ${env.*} variables are set, defaulted, or missing
Usage
lightshuttle secrets check [OPTIONS]
Options
| Option | Default | Description |
|---|---|---|
--env-file <ENV_FILE> | Path to a .env file to check against. Defaults to .env when present in the working directory; silently skipped when absent |
Explanation
Explanation is understanding-oriented: it steps back from the day-to-day tasks to discuss how LightShuttle works and why it is built the way it is. Read this section when you want the reasoning behind a design rather than instructions for a task.
This section currently covers:
- The crate architecture: the workspace layout, the dependency rule, and the control plane versus the runtime.
- The resource lifecycle: startup ordering, readiness gating, supervision, and graceful shutdown.
- Networking and service discovery: the per-project network,
hostname-by-name addressing, and how
${resources.*}andLSH_*interact.
The crate architecture
LightShuttle is a single Cargo workspace split into focused crates. The split is not cosmetic: it encodes one rule that keeps the project honest as it grows. The manifest model, which is the domain, depends on nothing internal, and every other crate depends toward it. Reading the graph below from the bottom up is reading the project from its core outward.
graph TD
cli["lightshuttle (CLI)"]
subgraph control_surface["Control surface"]
control["lightshuttle-control"]
otel["lightshuttle-otel"]
end
subgraph execution["Execution and rendering"]
runtime["lightshuttle-runtime"]
export["lightshuttle-export"]
end
subgraph resolution["Resolution"]
spec["lightshuttle-spec"]
end
subgraph foundation["Foundation"]
manifest["lightshuttle-manifest"]
secrets["lightshuttle-secrets"]
end
cli --> control
cli --> export
cli --> otel
cli --> runtime
cli --> secrets
cli --> manifest
control --> runtime
otel --> runtime
otel --> manifest
runtime --> spec
runtime --> manifest
export --> spec
export --> manifest
spec --> manifest
An arrow reads “depends on”. Notice that no arrow ever points back down: the foundation never imports the layers above it.
The dependency rule
The arrows only flow one way, from the concrete toward the abstract. The
domain, how a lightshuttle.yml is shaped and validated, lives in
lightshuttle-manifest and pulls in no other LightShuttle crate. The details,
how a resource becomes a running Docker container, how the result is exported
to Compose or Helm, how telemetry is collected, all sit above it and depend on
it, never the reverse.
This is what lets the project stay testable and swappable. The runtime targets
a narrow ContainerRuntime trait rather than Docker directly, so the lifecycle
logic can be exercised against a mock with no daemon in sight. A second backend
could be added without touching the manifest model. The rule is the reason a
change to “how we talk to Docker” cannot ripple down into “what a manifest
means”.
The layers
Foundation. lightshuttle-manifest is the strongly typed model of the
manifest: the parser, the ${...} interpolation engine, the validation pass
that catches naming, dependency and reference mistakes, and the JSON Schema
generator. lightshuttle-secrets is a standalone helper for loading and
checking environment variables. Both are pure: no Docker, no network, no HTTP.
Resolution. lightshuttle-spec is the bridge between intent and execution.
It resolves one manifest resource into a ContainerSpec, the self-contained
description of a container to start, together with the outputs that resource
exposes to its dependents (its host, port, url, and so on). This is where
a declarative postgres: block becomes concrete defaults like image 16, user
postgres, port 5432.
Execution and rendering. lightshuttle-runtime owns the ContainerRuntime
trait, its DockerRuntime implementation, and the LifecycleManager that
coordinates startup, supervision and shutdown of a whole stack. The same
resolved model is reused by lightshuttle-export, which follows a compiler
shape: it lowers the manifest into a neutral export model, then an emitter
renders that model into Compose, Helm or Kubernetes artifacts. One resolution
feeds both “run it now” and “ship it elsewhere”.
Control surface. lightshuttle-control is the developer-facing plane that
runs alongside a stack: a REST API, a WebSocket log stream and a server-rendered
dashboard, all bound to 127.0.0.1. CLI subcommands such as restart are thin
HTTP clients of these same endpoints. lightshuttle-otel bundles an
OpenTelemetry collector container and injects the environment that wires the
stack to it. Both observe or steer the runtime; neither is on its critical path.
Composition root. The lightshuttle crate is where everything is wired
together. It holds the clap command tree and the per-command implementations,
and the binary itself is a thin shim over its run entry point. Because the
command tree is library code, the workspace tooling can read it to generate the
CLI reference instead of hand-maintaining it.
Where to go next
- To see how the runtime turns this static graph into a running, ordered stack, read The resource lifecycle.
- To understand how resolved resources find each other at runtime, read Networking and service discovery.
- For the exact shape of every manifest field, see the manifest reference.
The resource lifecycle
A LightShuttle stack is not a flat list of containers to start. It is a graph,
and the runtime treats it as one. Understanding that single idea explains why
up boots in the order it does, why a dependent never starts too early, and why
down and Ctrl+C leave nothing behind.
A stack is a dependency graph
Every dependency edge, whether you wrote it as an explicit depends_on or
created it implicitly with a ${resources.<name>.*} interpolation, becomes an
edge in a graph. The two kinds are merged and de-duplicated, so the graph is the
single source of truth about ordering. From it the runtime builds a lifecycle
plan: a topological walk where a resource is only reached once everything it
depends on has been reached.
The payoff of modelling it as a graph rather than a sequence is parallelism with correctness. Resources that share no edge have no reason to wait for each other, so they start concurrently. Only real dependency edges force serialisation. A stack with one database and three unrelated services boots the three services in parallel, each gated only on the database.
Startup is gated on readiness, not on “started”
Starting a container is not the same as the thing inside it being usable. A Postgres process accepts the start command long before it accepts connections. If a dependent started the moment its dependency’s container existed, it would race a half-ready service and fail intermittently. That class of flake is exactly what the lifecycle is designed to remove.
So the runtime gates on readiness, not on existence. A resource is ready, and only then unblocks its dependents, when:
- its healthcheck succeeds, if one is defined; or
- its container reports a
runningstate, if none is defined.
The managed kinds (postgres, redis) ship a built-in healthcheck, so a
dependent waits for a real connection rather than a started process. For a plain
container, “running” is the default, which is why adding your own healthcheck
matters when the process needs warm-up time. The how-to guide
Wire dependencies and gate on readiness
covers how to declare those edges and checks.
Lifecycle events are the spine
As it walks the plan, the manager emits a stream of events for each resource: it has started, it has become healthy, or it has failed. These events are not just logging. They are the contract the rest of the system observes: the control plane consumes them to drive the dashboard, and the metrics pump uses the started-to-healthy transition to record how long each resource took to come up. Modelling progress as an event stream, rather than as polled state, is what lets several observers watch the same boot without coupling to the runtime’s internals.
Shutdown is the reverse walk, with a grace window
Once the stack is up, the manager supervises it and waits for a signal. On SIGINT (Ctrl+C) or SIGTERM it tears the stack down in the reverse of the startup order: dependents stop before the resources they relied on, so nothing is pulled out from under a still-running consumer.
Each resource is given a grace window to stop cleanly. The runtime sends SIGTERM
and waits; only if the resource overruns the window does it escalate to SIGKILL.
The window defaults to 30 seconds and is tunable with --grace, which is the
knob you reach for when a service needs longer to flush in-flight work. After
the containers are gone, the per-project network is removed too, so a stopped
stack leaves no orphaned Docker resources behind. The teardown is written to
stay idempotent: a partial shutdown can be re-run safely.
Why this shape
A development orchestrator earns trust by being boring in two moments: boot and
teardown. Boot must be reproducible, so a teammate running up on a fresh
checkout sees the same ordering you do, with dependents never observing a
half-ready dependency. Teardown must be complete, so iterating dozens of times a
day does not silently accumulate dead containers and networks. The graph model,
readiness gating and reverse-order shutdown with a grace window are the three
mechanisms that together make those two moments dependable.
Where to go next
- To see how a ready resource is actually reached over the network, read Networking and service discovery.
- For the crate that owns this logic, read The crate architecture.
- For the task-level steps, see Wire dependencies and gate on readiness.
Networking and service discovery
Once the lifecycle has a resource running and ready, its dependents still have to find it. LightShuttle’s answer rests on one decision: every project gets its own private network, and inside it resources address each other by name. Two projects never share that world, and dependency values flow in as resolved strings rather than as anything your code has to look up.
One private network per project
At up time the runtime creates a dedicated Docker bridge network named
lightshuttle-<project> and removes it at down. The choice to scope a network
per project, rather than dropping every container onto one shared network, buys
two things.
The first is isolation. A resource named db in one project is simply
unreachable from another project’s db, so you can run several stacks side by
side without name or port collisions. The second is a clean teardown boundary:
when the stack stops, its whole network goes with it, which is part of why a
stopped stack leaves nothing behind.
Network creation is idempotent on purpose. If two resources start concurrently
and both try to create the network, the second one’s 409 Conflict is treated
as success, because the only thing that matters is that the network exists.
Resources are addressed by name, not by port
Each container joins the project network with a DNS alias equal to its resource
name. That alias is the hostname its peers use. A resource’s host output
resolves to exactly this alias, which is why you address a dependency by the
name you gave it in the manifest and never by an IP address or a guessed port.
This is the deliberate difference from talking to a service through a published host port. Published ports exist so you, on the host, can reach Postgres or your app from a database GUI or a browser. Inside the network, peers skip the host entirely and connect to the container directly by name. The name is stable across restarts in a way an assigned port is not.
Dependency values arrive already resolved
A resolved resource exposes a set of outputs to its dependents: host, port,
url, and for the managed kinds also database, user and password. The
runtime delivers those outputs into a dependent in two forms, on purpose.
The first form is explicit interpolation. When you write
DATABASE_URL: ${resources.db.url}, the dependency is recorded and the variable
is substituted with the resolved value at boot. Your application reads a plain
DATABASE_URL, the variable every ecosystem already understands, with no
LightShuttle knowledge baked into the code. That keeps the application portable:
it runs the same outside LightShuttle.
The second form is automatic. For every dependency, each output is also injected
as an environment variable named LSH_<DEP>_<PROPERTY>, upper-cased, for
example LSH_DB_URL or LSH_DB_HOST. This is a zero-configuration escape hatch
for wiring a quick script without editing the manifest. The two coexist by
design: reach for explicit interpolation in application code you might run
elsewhere, and for the LSH_* variables for throwaway glue.
The model in one manifest
The manifest below is the whole model in miniature. api reads the database URL
by interpolation, which both creates a dependency on db and hands api the
resolved ${resources.db.url} at boot. On the project network, that URL points
at the host db, the resource’s network alias.
project:
name: discovery-model
resources:
db:
postgres:
version: "16"
api:
container:
image: alpine:3.20
command: ["sh", "-c", "echo using $DATABASE_URL && sleep 3600"]
env:
DATABASE_URL: ${resources.db.url}
Where to go next
- For the step-by-step task, including the full
LSH_*table, see Reach one resource from another. - To understand when a dependency is considered ready to be reached, read The resource lifecycle.
- For the exact outputs each resource kind exposes, see the manifest reference.