Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

git-launcher

A minimal, auditable Git launcher image for dstack. Given a config file that names an upstream Git repo and a full commit SHA, the launcher fetches that exact commit, verifies HEAD after checkout, and runs the workload's own entry point script — with no fallback to branches, tags, or short SHAs.

The name is literal: this image launches arbitrary Git input, but only at an explicit commit selected by attested config. It does not make the workload trustworthy by itself. Its job is to make the identity of what runs in the TEE checkable by combining TEE attestation, an auditable launcher image digest, and an attested config that names the workload commit. Whether the workload at that commit is itself trustworthy is up to the auditor.

By convention, the workload repo provides its own bash entry point at entrypoint.sh (default mode). This keeps install/build/run logic inside the workload repo, where it is covered by source provenance of the pinned COMMIT_SHA and is not a trust-bearing field in the launcher config. A verifier therefore audits two things: the launcher image's identity, and the REPO_URL + COMMIT_SHA pair (plus REPO_SUBDIR and ENTRYPOINT_SCRIPT when used, since each selects which script runs) in the attested config.

The launcher image is generic: its digest attests the launcher's implementation, not the workload. The workload identity comes from the config file, which must be attested separately (see Trust model).

This is a separate example from launcher/, which is a Docker Compose auto-update pattern. This launcher does the opposite — it prevents auto-update by pinning to one full commit SHA per deploy.

Preparing a workload repo

For a repo you control, use default mode. Put a bash entry script in the repo and let the launcher do only the Git pinning:

example-workload/
  entrypoint.sh
  ...

entrypoint.sh is the workload boundary. It should:

  • Install or validate whatever runtime the workload needs.
  • Build or prepare the application from the pinned source tree.
  • Be idempotent across container restarts.
  • Fail closed when install, build, config validation, or startup fails.
  • exec the long-running workload process so it becomes PID 1.
  • Keep mutable state, databases, uploads, retained bodies, and build caches outside WORK_DIR.

A minimal shape:

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null && pwd)
cd "$SCRIPT_DIR"

# Example only: replace with your workload's real install/build/run steps.
./scripts/build.sh
exec ./bin/server

Deployment config should keep the source checkout and workload state separate:

services:
  workload:
    image: docker.io/<org>/git-launcher@sha256:<launcher-digest>
    command: ["/etc/git-launcher/config.conf"]
    configs:
      - source: pin
        target: /etc/git-launcher/config.conf
    environment:
      APP_CONFIG_PATH: /var/lib/example-workload/config.json
    volumes:
      - workload-checkout:/var/lib/git-launcher
      - workload-state:/var/lib/example-workload
      - /var/run/dstack.sock:/var/run/dstack.sock

configs:
  pin:
    content: |
      REPO_URL=https://github.com/example-org/example-workload.git
      COMMIT_SHA=<full-40-or-64-hex-sha>
      WORK_DIR=/var/lib/git-launcher/example-workload

volumes:
  workload-checkout:
  workload-state:

Use Docker Compose environment: for non-secret runtime configuration that should be visible in the attested deployment. Use dstack encrypted secrets, dstack KMS, or mounted secret files for secrets. If the workload needs dstack KMS keys or quotes, mount /var/run/dstack.sock and let the workload use the dstack SDK.

Before deploying, audit the workload commit, then put its full commit hash in COMMIT_SHA. Do not use a branch, tag, or short SHA; the launcher rejects them.

What this is — and what it is not

The launcher is not the workload. It is intentionally tiny so the contents of this directory at a given commit can be read end-to-end before trusting it to bootstrap anything else.

Layer Lives in Job
Launcher this directory Fetch and run one program from one pinned upstream commit.
Workload a separate upstream Git repo The actual application — business logic, secrets handling, network surface.

The launcher's only job is to make sure that, given a config, the bytes that end up running inside the dstack VM are exactly the bytes at a specific commit in a specific upstream Git repo. Everything else lives in that upstream repo.

Trust model

The launcher image and the config file are two separate trust inputs, and a verifier must attest both. The launcher image alone does not determine which workload commit runs.

For a step-by-step verifier checklist that chains dstack attestation to the pinned workload commit, see VERIFY.md.

launcher image digest ──►  launcher implementation identity
                           (this directory at commit L; the release
                            workflow publishes a Sigstore build-provenance
                            attestation that binds the image digest to a
                            specific GitHub workflow run / repo / ref /
                            SHA. This is a signed chain of custody, not a
                            claim of bit-for-bit reproducibility.)

launcher config file  ──►  workload pin
                           (REPO_URL + full COMMIT_SHA U; selects which
                            upstream commit gets fetched and run)

                       ──►  workload running inside the TEE
                           = workload repo at commit U,
                             starting from its entrypoint.sh

The published launcher image is a generic runner: the same image digest can drive any pinned workload, depending on which config it is started with. The config is therefore part of the deployment's trust surface and must be attested separately. dstack provides a few standard ways to do that — pick the one that matches how strictly you want to bind the workload pin to the attestation:

Binding What attests the config When to use
dstack app config / compose_hash / config_id dstack measures the compose file (and any files it references that participate in compose-hash) into the TEE's attested config; a verifier compares against an expected hash Default for production. The config travels with the deployment and is covered by the existing dstack attestation chain.
Baked into a derived image Build a small downstream image FROM <launcher>@sha256:… that COPYs the config in; deploy that derived image. The derived image digest then implies both the launcher and the pin When you want image-digest-only binding (one digest fully determines the workload).
Runtime bind-mount from the host Nothing — the host can swap the file Local development only. Do not use for production trust.

Once the config is attested by one of the first two options, a relying party verifies in four steps:

  1. The launcher image digest in the dstack attestation matches the digest published by the release workflow for this directory at commit L (verified via the Sigstore build-provenance attestation, which binds the digest to a specific GitHub Actions workflow run / repo / ref / SHA — see VERIFY.md for the exact check).
  2. The launcher script at commit L is the audited script — small, parses (does not source) its config, refuses anything but a full commit SHA, and verifies HEAD after checkout.
  3. The launcher config the runtime actually loaded is the attested config (via compose_hash / config_id, or by deriving it from the derived image's digest).
  4. The COMMIT_SHA in that config is the workload commit the relying party expected.

Because the launcher does no fallback — missing or invalid commit is a hard failure — there is exactly one workload commit that can ever boot from a given (launcher image digest, attested config) pair.

CLI

git-launcher <config-file>

The launcher is a single bash script (bin/git-launcher). It depends only on bash, git, and POSIX coreutils. It is not sourced and does not source the config. In default mode, the only bytes it executes are those of the workload repo's entrypoint.sh at the pinned COMMIT_SHA. In advanced mode (see below), it additionally executes the configured INSTALL_CMD / RUN_CMD via bash -c.

Config contract

An env-file with KEY=VALUE lines. Comments start with #. Surrounding matching single or double quotes are stripped (one layer). Unknown keys are rejected. The config is parsed, not sourced — no command substitution and no shell expansion in the parse step.

Required

Key Meaning
REPO_URL Git URL of the upstream workload repo (https://… or git@…).
COMMIT_SHA Full 40-hex SHA-1 or 64-hex SHA-256. Branches, tags, and short SHAs are rejected.
WORK_DIR Local directory used as the checkout. Created if missing. Reused on subsequent runs as long as the existing clone's origin URL matches REPO_URL. Scrubbed before each launch; keep mutable app state outside this directory.

Optional

Key Meaning
REPO_SUBDIR Relative directory inside the repo to cd into before running the entry point or RUN_CMD. Must not be absolute and must not contain ...
ENTRYPOINT_SCRIPT Relative path (inside REPO_SUBDIR or repo root) to the bash entry script for default mode. Defaults to entrypoint.sh. Must not be absolute and must not contain ... Trust-bearing in default mode — like REPO_SUBDIR, it selects which script runs.
RUN_CMD Advanced. Shell command to exec instead of the default entrypoint.sh. Use only when the workload repo cannot host its own entry script.
INSTALL_CMD Advanced. Shell command to run before RUN_CMD. Only valid alongside RUN_CMD.

Default mode: entrypoint.sh in the workload repo

Recommended for every workload you control. The workload repo provides a bash script at the fixed path entrypoint.sh (at the repo root, or at REPO_SUBDIR/entrypoint.sh if REPO_SUBDIR is set). The launcher runs it with bash entrypoint.sh after checkout — no executable bit is required. All install/build/run logic lives in that script.

In this mode the trust-bearing config in the launcher's config file is REPO_URL + COMMIT_SHA (and REPO_SUBDIR if used, since it selects which entrypoint.sh runs). WORK_DIR is local plumbing — it names where on the in-TEE filesystem to keep the checkout — and is not trust-bearing. Normal process environment variables are inherited by the workload. Use Docker Compose environment: for non-secret runtime config that should be visible at launch, and use dstack encrypted secrets / KMS / mounted secret files for secrets.

Because entrypoint.sh's bytes are pinned by COMMIT_SHA and stored in the workload repo, they are covered by source provenance of the pinned commit. The verifier does not need to extract or audit any command string out of the launcher config.

Advanced mode: explicit RUN_CMD / INSTALL_CMD

Use this when the workload repo cannot be modified to add a entrypoint.sh (e.g. you are pinning a third-party repo unchanged). Setting RUN_CMD switches the launcher into advanced mode; if you need more than one command, set INSTALL_CMD to run before RUN_CMD. Each is a single-line shell string and the launcher does not implement multi-line parsing. In this mode both values are trust-bearing config and must be audited alongside COMMIT_SHA.

What the launcher will and will not do

  • Will: clone fresh if WORK_DIR is empty; reuse the existing clone otherwise (after asserting that its origin URL matches REPO_URL).
  • Will: git fetch --tags --prune origin, git checkout --detach $SHA, git reset --hard $SHA, git clean -ffdx, then git rev-parse HEAD and assert it equals COMMIT_SHA.
  • Will not: fall back to a branch, tag, or HEAD if the commit is missing. A missing commit is a hard failure.
  • Will not: accept short SHAs. A truncated SHA could resolve ambiguously if the upstream history changes.
  • Will not: source the config or eval config values. In default mode the launcher executes bash entrypoint.sh from the pinned commit; in advanced mode it executes INSTALL_CMD / RUN_CMD via bash -c. Nothing else from the config reaches a shell.

Persistent volume and reboot behavior

WORK_DIR should normally live on a persistent Docker volume, for example /var/lib/git-launcher/<workload>. On first boot, git-launcher creates the directory and clones REPO_URL there. On reboot or container restart, the same directory is reused only if it is already a git checkout whose origin exactly matches REPO_URL.

Every boot still runs the same verification path: fetch from origin, detach checkout to COMMIT_SHA, reset tracked files, remove untracked files with git clean -ffdx, then assert git rev-parse HEAD == COMMIT_SHA. The checkout volume is therefore a source cache only. It must not hold application state, SQLite databases, uploaded files, or build artefacts that the workload expects to survive; put those in a separate workload-owned volume. If the volume is empty, the launcher reclones. If the volume is non-empty but not a git checkout, or if it points at a different origin, startup fails.

Example

See examples/web-app.conf. Adapt REPO_URL, COMMIT_SHA, and (if you need it) REPO_SUBDIR for your workload, and make sure the workload repo has a entrypoint.sh at the pinned commit.

./bin/git-launcher ./examples/web-app.conf

The launcher logs the resolved repo, commit, workdir, and selected mode at startup, then logs the verified HEAD after checkout, before handing control to entrypoint.sh (or INSTALL_CMD / RUN_CMD in advanced mode).

Deploying with dstack

Always pin the launcher image by its OCI digest (@sha256:…) — not by tag — so the dstack attestation binds to the exact launcher bytes you audited. How the config gets in front of the launcher depends on which binding from the trust model above you chose.

Mounting the dstack socket

If the workload uses the dstack SDK (Rust, Python, etc.) to request KMS keys or TDX quotes — the recommended pattern for workloads that need in-TEE identity — the dstack agent's Unix socket must be visible inside the workload container. By default the SDK looks at /var/run/dstack.sock. Mount it in every compose snippet below; the snippets already include the mount.

If your workload talks to a non-default dstack endpoint, set the endpoint variable your workload reads through Docker Compose environment: rather than baking it into the launcher config. It is runtime configuration, not part of the Git pin.

Local development (host bind-mount)

Convenient for iterating on the config. Not for production: the host can swap the mounted file at any time and nothing about that swap is reflected in the dstack attestation.

services:
  workload:
    image: docker.io/<org>/git-launcher@sha256:<launcher-digest>
    command: ["/etc/git-launcher/config.conf"]
    volumes:
      - ./web-app.conf:/etc/git-launcher/config.conf:ro
      - workload-checkout:/var/lib/git-launcher
      - /var/run/dstack.sock:/var/run/dstack.sock
    restart: unless-stopped

volumes:
  workload-checkout:

Production option A (recommended): attest the config via dstack compose

Inline the config inside the compose file (or reference a sibling file that participates in the compose hash). dstack measures the compose into the attested app config, so a verifier can compare the deployed compose against the one they audited:

services:
  workload:
    image: docker.io/<org>/git-launcher@sha256:<launcher-digest>
    command: ["/etc/git-launcher/config.conf"]
    configs:
      - source: pin
        target: /etc/git-launcher/config.conf
    volumes:
      - workload-checkout:/var/lib/git-launcher
      - /var/run/dstack.sock:/var/run/dstack.sock
    restart: unless-stopped

configs:
  pin:
    content: |
      REPO_URL=https://github.com/example-org/example-web-app.git
      COMMIT_SHA=<full-40-or-64-hex-sha>
      WORK_DIR=/var/lib/git-launcher/example-web-app

volumes:
  workload-checkout:

A verifier compares the deployed compose_hash / config_id against the one they audited; that binds the launcher image and the pinned COMMIT_SHA to the attestation.

Production option B: bake the config into a derived image

If you want a single digest to fully determine the workload, build a small downstream image that copies the config in:

FROM docker.io/<org>/git-launcher@sha256:<launcher-digest>
COPY web-app.conf /etc/git-launcher/config.conf
CMD ["/etc/git-launcher/config.conf"]

Deploy that derived image (pinned by its own @sha256:…). The derived image digest now implies both the launcher and the workload pin. Still mount /var/run/dstack.sock from the host into the workload container in the deploying compose if the workload uses the dstack SDK.

Tests

tests/run-tests.sh builds a throwaway local git repo, points the launcher at specific commits, and asserts:

  • Happy path: launcher checks out the pinned commit and execs the run command from inside the requested subdirectory.
  • Re-running with a different COMMIT_SHA advances the pin in-place.
  • Bogus commit SHA aborts before running anything.
  • Branch names and short SHAs are rejected during validation.
  • Missing required keys are rejected.
  • Unknown keys are rejected.
  • REPO_SUBDIR containing .. is rejected.
  • Pre-existing WORK_DIR whose origin differs from REPO_URL is rejected.
  • Normal process environment variables reach the workload.
  • INSTALL_CMD runs before RUN_CMD.
  • --help exits zero.
  • The release workflow runs launcher tests before building the image and generates a GitHub artifact attestation bound to the pushed image digest.
  • The Dockerfile uses a small runtime base and exposes the launcher as the entrypoint.

Run with:

./tests/run-tests.sh

The tests only require bash, git, and standard coreutils, so they run unprivileged in CI or on a developer laptop.

Release image provenance

The release workflow (.github/workflows/git-launcher-release.yml in this repository's root .github/) follows the dstack-examples pattern:

  1. run ./tests/run-tests.sh;
  2. build and push docker.io/${DOCKERHUB_ORG}/git-launcher:<tag>;
  3. call actions/attest-build-provenance@v1 with the Docker build digest;
  4. write the digest and a Sigstore search link into both the GitHub Actions step summary and the GitHub release body.

The attestation subject is the immutable OCI digest emitted by docker/build-push-action, not the mutable tag. A verifier should pin and compare that digest before trusting the launcher image.

Audit checklist

If you are reviewing this directory at commit L before signing off on a launcher image, the relevant audit surface is:

  1. bin/git-launcher — every line. Confirm:
    • No eval, no source/., no command substitution applied to config values during parsing.
    • git checkout always uses the verbatim COMMIT_SHA and the result is reverified with git rev-parse.
    • INSTALL_CMD / RUN_CMD are executed exactly once each, via a fresh bash -c, with no implicit fallbacks.
  2. The config the launcher will load at deploy time (REPO_URL, COMMIT_SHA, etc.). This pins which workload code runs, and is not covered by the launcher image digest — verify it via the dstack attested compose_hash / config_id, or via the digest of a derived image that bakes the config in. See Trust model.
  3. The contents of the upstream workload repo at the pinned COMMIT_SHA — that is the surface that actually serves traffic.