Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 colima works.
  • A Rust toolchain. The recommended way to install it is rustup. LightShuttle’s MSRV is documented in docs/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.

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 check reports what install would do without writing anything.
  • lightshuttle alias uninstall removes the alias.
  • --shell <bash|zsh|fish|powershell> overrides auto-detection and --yes skips 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-server modeline 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.name identifies the stack. The orchestrator uses it as a prefix for every container it creates, so two LightShuttle projects never collide.
  • The resources section is a map of resource names to resource definitions. Each entry has exactly one kind key (postgres, redis, container, dockerfile).
  • db is a Postgres 16 instance. With no further configuration, the runtime expands version: "16" into the official postgres:16-alpine image, generates a random password and binds an auto-named persistent volume.
  • app is a plain container based on alpine:3.20. Its env block uses the interpolation form ${resources.db.url}, which the orchestrator resolves at boot to the full Postgres URL of the db resource. That reference also creates an implicit dependency: app will not start until db is 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:

  1. The manifest is validated.
  2. Resources are started in topological order. db starts first.
  3. The orchestrator polls the Postgres healthcheck until it succeeds.
  4. app starts, with DATABASE_URL injected and pointing at db.
  5. 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_URL from ${resources.api_db.url}.
  • REDIS_URL from ${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:

VariableSource
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

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:

NameKindStatusHealthyImageActions
dbpostgresrunningyespostgres:16-alpineRestart
cacheredisrunningyesredis:7-alpineRestart
apicontainerstartingnoalpine:3.20Restart
  • 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}/restart and 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.js and the stylesheet from /_assets/style.css. Both responses include a Cache-Control: public, max-age=3600 header.
  • 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 runningstartingrunning again without a full page reload.
  • GET /resources/{unknown} returns 404 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>.
  • --force overwrites 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:

  1. Scaffold an empty project directory.
  2. Write a small HTTP service that reads DATABASE_URL and runs one query.
  3. Add a Dockerfile so LightShuttle builds the service for you.
  4. Declare a two-resource manifest: a Postgres database and your service.
  5. Boot, observe through the CLI, visit the dashboard, shut down.

What you need

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

StackHTTP layerPostgres driverBase image
Node.jsbuilt-in node:httppgnode:22-alpine
Pythonbuilt-in http.serverpsycopg 3python:3.12-slim
Gonet/httppgx v5golang:1.23-alpine
Rustaxumtokio-postgresrust: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_URL is 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 Pool connects 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:

  • db is a Postgres 16 instance. LightShuttle expands it to the official postgres:16-alpine image, generates a password and binds a persistent volume.
  • api is built from the Dockerfile in the current directory (context: .), selecting the dev stage.
  • env.DATABASE_URL is set to ${resources.db.url}. That interpolation resolves at boot to the full Postgres URL of db, and it also makes api depend on db: the service will not start until the database is healthy. No explicit depends_on is needed.
  • ports: [8080] publishes the container port on your host so you can reach the service from a browser or curl.

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

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_URL is never hard-coded. LightShuttle injects it at boot, pointing at the database resource. The same code runs unchanged against any Postgres.
  • psycopg.connect opens 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 Python datetime, 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:

  • db is a Postgres 16 instance. LightShuttle expands it to the official postgres:16-alpine image, generates a password and binds a persistent volume.
  • api is built from the Dockerfile in the current directory (context: .), selecting the dev stage.
  • env.DATABASE_URL is set to ${resources.db.url}. That interpolation resolves at boot to the full Postgres URL of db, and it also makes api depend on db: the service will not start until the database is healthy. No explicit depends_on is needed.
  • ports: [8080] publishes the container port on your host so you can reach the service from a browser or curl.

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

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_URL is never hard-coded. LightShuttle injects it at boot, pointing at the database resource. The same code runs unchanged against any Postgres.
  • pgxpool.New creates 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:

  • db is a Postgres 16 instance. LightShuttle expands it to the official postgres:16-alpine image, generates a password and binds a persistent volume.
  • api is built from the Dockerfile in the current directory (context: .), selecting the dev stage.
  • env.DATABASE_URL is set to ${resources.db.url}. That interpolation resolves at boot to the full Postgres URL of db, and it also makes api depend on db: the service will not start until the database is healthy. No explicit depends_on is needed.
  • ports: [8080] publishes the container port on your host so you can reach the service from a browser or curl.

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

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_URL is never hard-coded. LightShuttle injects it at boot, pointing at the database resource. tokio_postgres::connect returns a pair (client, connection): the connection drives the protocol wire and must be polled to completion, so we hand it to tokio::spawn as a background task. The client is then free to issue queries while the connection task runs independently. We use select now()::text to fetch the timestamp as a plain string, avoiding any dependency on a date library such as chrono.
  • The main function reads PORT with a fallback of 8080, 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:

  • db is a Postgres 16 instance. LightShuttle expands it to the official postgres:16-alpine image, generates a password and binds a persistent volume.
  • api is built from the Dockerfile in the current directory (context: .), selecting the dev stage.
  • env.DATABASE_URL is set to ${resources.db.url}. That interpolation resolves at boot to the full Postgres URL of db, and it also makes api depend on db: the service will not start until the database is healthy. No explicit depends_on is needed.
  • ports: [8080] publishes the container port on your host so you can reach the service from a browser or curl.

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

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:

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.

  1. A dotenv file (.env in the working directory by default).
  2. 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:

StatusMeaning
set (.env)resolved from the dotenv file
set (env)resolved from the process environment
default (...)unset, falling back to the declared default
missingunset, and at least one reference has no default

When at least one variable is missing, the command exits non-zero.

validate does not check secrets. lightshuttle validate parses the manifest, resolves ${resources.*} references and checks the dependency graph, but it deliberately does not resolve ${env.*} values. Use secrets check to audit secrets, and rely on the fail-fast preflight of up as the final guard. The two read from the same engine, so secrets check predicts what up will 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 running state, 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"
  • test is required; its first element should be CMD (exec form) or CMD-SHELL (run through a shell).
  • interval, timeout and start_period are Go duration strings ("5s", "500ms", "2m"); they default to 5s, 3s and 5s.
  • retries is the number of consecutive failures before the resource is marked unhealthy; it defaults to 5.
  • start_period is 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:

VariableResolves 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 standard DATABASE_URL that 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:

VariableValue
OTEL_EXPORTER_OTLP_ENDPOINThttp://<project>_lightshuttle_otel:4317
OTEL_SERVICE_NAMEthe resource name
OTEL_RESOURCE_ATTRIBUTESservice.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:

CodeMeaning
0Success: the stack started and shut down cleanly.
1User error: invalid manifest, missing file, failed validation, or a resource that ended in a failed state.
2Runtime error: the container runtime is unreachable or a container failed to start or build.
130Interrupted 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:

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

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.

FieldTypeRequiredDefaultDescription
descriptionstringnoFree-form description displayed in the local dashboard.
namestringyesProject 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.
versionstringnoFree-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).

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

FieldTypeRequiredDefaultDescription
otelOtelConfignoOpenTelemetry 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].

FieldTypeRequiredDefaultDescription
composeComposeExportnoPer-resource overrides for the docker-compose export target.
helmHelmExportnoPer-resource overrides for the Helm chart target.
kubernetesKubernetesExportnoPer-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].

FieldTypeRequiredDefaultDescription
databasestringnoInitial database name created at first startup. Must match ^[a-z][a-z0-9_]{0,62}$. Defaults to the resource name when unset.
depends_onarray of stringnoNames of other resources this instance must wait for before starting.
healthcheckHealthchecknoHealthcheck override. Replaces the built-in pg_isready check. See [Healthcheck] for field semantics and defaults.
imagestringnoExplicit image reference. Takes precedence over version. Use this to pin a specific digest or to point to a private registry.
passwordstringnoSuperuser password. The runtime generates a random password when this is unset and exposes it via ${resources.name.password}.
portintegernoHost port the container port 5432 is mapped to. The runtime chooses a random free port when unset.
userstringnoSuperuser account name. Defaults to "postgres" when unset.
versionstringnoPostgreSQL major version, e.g. "16". Expanded into postgres:<version>-alpine when image is absent. Ignored when image is set.
volumeVolumenoPersistent 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.

FieldTypeRequiredDefaultDescription
depends_onarray of stringnoNames of other resources this instance must wait for before starting.
healthcheckHealthchecknoHealthcheck override. Replaces the built-in redis-cli PING check. See [Healthcheck] for field semantics and defaults.
imagestringnoExplicit image reference. Takes precedence over version.
passwordstringnoAuthentication password for the requirepass directive. An empty string or None runs Redis without authentication.
portintegernoHost port the container port 6379 is mapped to. The runtime chooses a random free port when unset.
versionstringnoRedis major version, e.g. "7". Expanded into redis:<version>-alpine when image is absent.
volumeVolumenoPersistent 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.

FieldTypeRequiredDefaultDescription
commandCommandnoOptional entrypoint override. See [Command] for the two accepted forms (string or argument list).
depends_onarray of stringnoNames of other resources this container must wait for before starting. Validated by [crate::Manifest::validate].
envmap of stringnoEnvironment variables injected into the container at startup. Values are interpolated: ${env.NAME} and ${resources.name.property} expressions are resolved at runtime.
healthcheckHealthchecknoOptional healthcheck. Overrides whatever is baked into the image. See [Healthcheck] for field semantics and defaults.
imagestringyesFull image reference including the tag, e.g. "nginx:1.25-alpine".
portsarray of PortMappingnoPort 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.
volumesarray of stringnoVolume 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_dirstringnoOptional 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].

FieldTypeRequiredDefaultDescription
build_argsmap of stringnoBuild-time ARG values passed to docker build --build-arg.
commandCommandnoOptional entrypoint override. See [Command] for accepted forms.
contextstringyesBuild 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_onarray of stringnoNames of other resources this build must wait for before starting. Validated by [crate::Manifest::validate].
dockerfilestringno"Dockerfile"Path to the Dockerfile within context. Defaults to "Dockerfile".
envmap of stringnoEnvironment variables injected into the container at runtime. Values support ${env.NAME} and ${resources.name.property} interpolation.
healthcheckHealthchecknoOptional healthcheck override. See [Healthcheck] for field semantics.
portsarray of PortMappingnoPort mappings between the host and the container. See [PortMapping].
targetstringnoMulti-stage build target passed to docker build --target.
volumesarray of stringnoVolume mappings in "host:container" or "named:container" form. Relative host paths are resolved by [crate::Manifest::resolve_host_volume_paths].
working_dirstringnoOptional 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].

FieldTypeRequiredDefaultDescription
resourcesmap of ComposeResourceExportnoPer-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].

FieldTypeRequiredDefaultDescription
enabledbooleannoWhether 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].

FieldTypeRequiredDefaultDescription
intervalstringno"5s"Time between consecutive check executions. Default "5s". Accepted suffixes: ns, us, ms, s, m, h.
retriesintegerno5Number of consecutive failures needed to declare the resource unhealthy. Default 5.
start_periodstringno"5s"Grace period at startup during which check failures are not counted toward retries. Default "5s".
testarray of stringyesCommand to run. The first element must be "CMD" or "CMD-SHELL". Cannot be empty (enforced by [crate::Manifest::validate]).
timeoutstringno"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.

FieldTypeRequiredDefaultDescription
chart_namestringnoChart name. Defaults to the project name during lowering.
chart_versionstringnoChart version (SemVer string). Defaults to the project version when set, otherwise "0.1.0".
replicasintegernoDefault replica count exposed via the generated chart’s values.yaml.
resourcesmap of HelmResourceExportnoPer-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].

FieldTypeRequiredDefaultDescription
enabledbooleannoWhether this resource is included in the chart. None or Some(true) includes the resource. Some(false) omits it.
replicasintegernoReplica 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.

FieldTypeRequiredDefaultDescription
image_pull_policyImagePullPolicynoDefault image pull policy applied to every resource. See [ImagePullPolicy] for accepted values. Defaults to [ImagePullPolicy::IfNotPresent].
namespacestringnoTarget Kubernetes namespace. Defaults to the project name when absent.
replicasintegernoDefault replica count for every resource that supports it.
resourcesmap of KubernetesResourceExportnoPer-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].

FieldTypeRequiredDefaultDescription
enabledbooleannoWhether this resource is included in the export. None or Some(true) includes the resource. Some(false) omits it.
image_pull_policyImagePullPolicynoImage pull policy override for this specific resource, taking precedence over [KubernetesExport::image_pull_policy].
replicasintegernoReplica 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.

FieldTypeRequiredDefaultDescription
enabledbooleannoWhether 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: 8080 maps 0.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 when lightshuttle down removes 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

OptionDescription
-f, --file <FILE>Path to the manifest. Overrides the upward discovery

Commands

CommandDescription
upBoot the stack and supervise it until interrupted
downStop every container managed by this project
psList managed resources and their status
logsStream logs of a single resource
validateParse and validate the manifest without starting anything
manifestDump the resolved manifest to stdout as YAML
restartRestart 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
aliasManage the optional lsh shell alias
exportGenerate deployment artifacts from the manifest
secretsInspect ${env.*} variable references in the manifest

up

Boot the stack and supervise it until interrupted

Usage

lightshuttle up [OPTIONS]

Options

OptionDefaultDescription
--grace <GRACE>10sSIGTERM-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-otelSkip 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

OptionDefaultDescription
--grace <GRACE>10sSIGTERM-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

ArgumentDescription
<RESOURCE>Resource name as declared in the manifest

Options

OptionDefaultDescription
-f, --followFollow 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

OptionDefaultDescription
--strictUpgrade 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

ArgumentDescription
<RESOURCE>Resource name as declared in the manifest

Options

OptionDefaultDescription
--detachReturn 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

OptionDefaultDescription
--shell <SHELL>Override shell auto-detection [possible values: bash, zsh, fish, powershell]
--yesSkip the confirmation prompt

check

Report what install would do, without writing anything

Usage

lightshuttle alias check [OPTIONS]

Options

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

OptionDefaultDescription
--shell <SHELL>Override shell auto-detection [possible values: bash, zsh, fish, powershell]
--yesSkip the confirmation prompt

export

Generate deployment artifacts from the manifest

Usage

lightshuttle export [OPTIONS] <TARGET>

Arguments

ArgumentDescription
<TARGET>Target format to generate [possible values: compose, kubernetes, helm]

Options

OptionDefaultDescription
-o, --output <OUTPUT>Output directory. Defaults to ./export/<target>
--forceOverwrite 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

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

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

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 running state, 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

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