Docs
Production deploy
Production is a single Docker host running two containers: the Last Light agent and a Caddy reverse proxy that handles HTTPS automatically. State lives in one Docker volume. The footprint is intentionally small — this is meant to run on a cheap VPS you own, not your production cluster.
LASTLIGHT_SANDBOX=docker). While that
isolation is strong, it is early-stage software and may have undiscovered
weaknesses. Put it on a host that cannot reach your internal systems,
databases, or credentials beyond what it explicitly needs.
/dev/kvm available, the
native systemd deploy
runs the harness directly under systemd and uses gondolin for sandboxing —
no Docker required. Re-deploys are a git pull plus
sudo bash deploy/native/install.sh.
Quick setup (recommended)
The fastest path from a bare server to a running instance — the setup wizard handles environment files, secrets, Docker Compose, and optional Caddy TLS:
npx lastlight setup
It walks you through entering your GitHub App credentials, domain, the
repositories the bot manages, your model provider API key
(OpenAI / Anthropic / OpenRouter), and optional Slack integration. When done it
scaffolds your private deployment overlay at instance/ —
writing instance/config.yaml (your managed repos),
instance/secrets/.env, and your PEM at
instance/secrets/app.pem — then offers to build and start the containers.
Manual setup
1. Fork and clone
git clone https://github.com/YOUR-USER/lastlight.git
cd lastlight
git remote add upstream https://github.com/nearform/lastlight.git
Forking is recommended so you can tweak workflows, prompts, and agent context to
match your project. Pull from upstream when you want updates.
2. Lay out your deployment overlay (instance/)
Everything specific to your deployment — managed repos, config overrides,
agent-context, and secrets — lives in a single instance/ folder next
to docker-compose.yml. It's mounted read-only at /app/instance
(LASTLIGHT_OVERLAY_DIR=/app/instance) and is never committed to the
public repo or baked into the image — so it's the natural home for a private
config repo.
mkdir -p instance/secrets
cp deploy/.env.production.example instance/secrets/.env
cp /path/to/your-app.private-key.pem instance/secrets/app.pem
chmod 600 instance/secrets/.env instance/secrets/app.pem
# instance/config.yaml — at minimum the repos the bot manages:
printf 'managedRepos:\n - your-org/repo-one\n' > instance/config.yaml Edit instance/secrets/.env and fill in at minimum:
GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY_PATH(use./app.pem— the entrypoint symlinks it)WEBHOOK_SECRETDOMAIN— your public hostname, used by Caddy for automatic TLSOPENAI_API_KEY,ANTHROPIC_API_KEY, and/orOPENROUTER_API_KEY— must match whichever providerLASTLIGHT_MODELresolves to (defaultanthropic/claude-sonnet-4-6). One OpenRouter key covers most providers if you want a single billing surface.
Both the agent and caddy services read
instance/secrets/.env via env_file, and the entrypoint
sources it inside the container — so no repo-root .env is
needed. instance/config.yaml is merged over the public
config/default.yaml at startup (maps deep-merge, arrays like
managedRepos replace, env vars override). Edit any overlay file and
docker compose restart agent to apply — no rebuild.
See the Configuration reference for every
variable the harness understands, including optional knobs like
LASTLIGHT_MODEL, LASTLIGHT_MODELS,
LASTLIGHT_THINKING, LASTLIGHT_SANDBOX,
APPROVAL_GATES, ADMIN_PASSWORD, and the Slack
OAuth group.
OPENCODE_* env vars (OPENCODE_MODEL,
OPENCODE_MODELS, OPENCODE_VARIANT,
OPENCODE_VARIANTS) are still read as fallbacks for the matching
LASTLIGHT_* name. You can leave existing .env files
in place and rename at your leisure.
3. Build and start
# Point Caddy at your domain (in instance/secrets/.env)
echo "DOMAIN=lastlight.example.com" >> instance/secrets/.env
# Build and start both containers
docker compose build agent
docker compose up -d
# Tail the logs
docker compose logs -f agent
Caddy reads DOMAIN from the environment and provisions a Let's
Encrypt certificate on first start. DNS must already resolve to the host.
4. Verify it's running
curl https://lastlight.example.com/health
# { "status": "ok" }
# Invalid webhook signature — returns 401, confirms the listener works
curl -X POST https://lastlight.example.com/webhooks/github -d '{}'
Open https://lastlight.example.com/admin in a browser and check the
Home tab. You should see live activity stats, recent workflows, and resource usage.
5. Wire up the webhook
Back in your GitHub App settings:
- Webhook URL →
https://lastlight.example.com/webhooks/github - Webhook secret → same value as
WEBHOOK_SECRETininstance/secrets/.env - Make sure Active is enabled.
Create a test issue on a repo where the App is installed; Last Light should react within a few seconds and you'll see a new run appear on the dashboard Workflows tab.
State and persistence
All persistent state lives under /app/data inside the container,
mounted as a Docker volume:
lastlight.db— the SQLite state database: executions, workflow runs, approvals, cron history, users, messaging sessions, feedback signals. Absent when you run on Postgres — the same tables live on your server instead (next section).agent-sessions/projects/— JSONL session files (Claude-SDK-style envelope written byevent-shim.ts— the full audit trail for every run, read by the dashboard). Override the location withLASTLIGHT_SESSIONS_DIR.sandboxes/— cloned repos, one per task (gondolin or docker).sandbox-data/— shared volume mounted into docker-mode sandboxes.logs/— structured harness logs.secrets/app.pem— mode-600 copy of the GitHub App key, used by sandbox containers via the shared volume.
Back this volume up if you care about the audit trail.
Using Postgres instead of SQLite
Both are supported production stores, and the choice is one
line of config — lastlight server setup asks it directly and
defaults to SQLite. Nothing else in the deployment differs: the same compose
stack, the same overlay, the same workflows, the same sandbox backends. The
schema, migrations and the entire state test suite run on both dialects, and
a third CI leg runs them over a real Postgres server and connection pool.
- SQLite (default) — needs nothing running. Set no
DATABASE_URLat all; the absence is the configuration, and the slot resolves to a file under$STATE_DIR. - Postgres — an external or managed server you supply: self-hosted, RDS, Cloud SQL, Supabase, or serverless Neon. Set
DATABASE_URLto apostgres://URL ininstance/secrets/.envand runlastlight server start agent. The harness applies its Postgres migrations at boot.
The driver is picked from the host: *.neon.tech
uses Neon's serverless WebSocket pool, everything else uses node-postgres.
Override with DATABASE_DRIVER=pg|neon — the case for setting it
by hand is Neon behind a custom domain, or forcing node-postgres against
Neon's TCP endpoint. DATABASE_POOL_MAX (default 10) bounds the
pool.
Choose Postgres when the state should outlive the host, when something else needs to read it, or when your operational tooling already assumes Postgres. It is not a throughput decision at Last Light's write volume, and it is not a route to running more than one instance (see the note at the end of this section).
Put the URL in .env, not in
instance/config.yaml. It is a valid config slot there, which is
exactly the trap — the overlay is a git repo with a GitHub remote, and a
credential committed to it cannot be un-committed by the dashboard's
render-time masking. lastlight server setup offers the choice
and writes it to the right file for you.
To move an existing deployment across, add the URL to
instance/secrets/.env first and then copy the data — with
DATABASE_URL already in place, none of these commands needs the
credential on the command line, so it never reaches your shell history or the
host's process list:
# 1. DATABASE_URL=postgres://… is now in instance/secrets/.env
lastlight server db check # can the agent reach it? (full probe, from inside the image)
lastlight server db migrate --dry-run # per-table row counts, writes nothing
lastlight server db migrate # copy, then verify the counts
lastlight server start agent # recreate, so the new env_file value is picked up
The migration stops the agent first (it asks), copies every table in
foreign-key order and verifies the row counts against the source. It refuses
a target that already has rows unless you pass --truncate. Pass
--to <url> only if the target is not the
DATABASE_URL the container already has. The last step is
start, not restart: a restart cannot pick up a new
env_file value.
Keep the SQLite file until you are satisfied — the copy is one-way, and
Postgres → SQLite is not supported. Both commands run
inside the agent image (docker compose run --rm --no-deps),
because that is where the drivers and schemas live, which also means the probe
runs from the network the harness itself will use.
Postgres here is a storage choice, not a step towards running several instances: the atomic operations behind the approval and run-lock lifecycle rely on an in-process lock, so Last Light is still one writer.
Updating
Updating lastlight itself (source, built-in assets) is a rebuild:
git fetch upstream
git merge upstream/main
docker compose build agent
docker compose up -d agent
Or let the CLI do it. With lastlight installed on the host,
lastlight server update pulls your overlay + the core repo,
rebuilds, recreates the stack, restarts the egress sidecars, and
health-checks — the same flow, with live progress. server
start\|stop\|restart\|status cover the rest of the lifecycle, and
the dashboard shows an "update available" banner when you're behind.
Pinning the core version. Add a deploy.version
(a git tag/ref, e.g. v0.10.6) to your overlay's
config.yaml to pin which core release this instance runs;
unset — or main/latest — tracks main:
deploy:
version: v0.10.6 lastlight server update (and server setup) then
checks core out at that tag and rebuilds, instead of following
main. This makes the overlay repo the single source of truth
for the deployed version — bump deploy.version, commit, and a
CI/CD job running lastlight server update converges the host.
When pinned, the dashboard drops the main-drift nudge: it
only warns "redeploy needed" if the running image is behind the pinned
tag, otherwise shows a quiet "pinned" label. (The
LASTLIGHT_CORE_VERSION env var overrides the file for CI.)
Updating your overlay (anything in instance/ — managed
repos, config, agent-context) is just a restart, no rebuild:
# edit instance/config.yaml (or git pull in instance/), then:
docker compose restart agent Sessions and the database survive rebuilds because they live in the volume, not the image.