.github/workflows/CI.yml lint + typecheck + build + test (parallel jobs)
.github/workflows/CD.yml tag-aware image build + push to GHCR
.github/ISSUE_TEMPLATE/ bug_report.yml, feature_request.yml, config.yml
Dockerfile single image, role-dispatched entrypoint
docker-compose.yml app + postgres (single host, self-host)
scripts/start.sh entrypoint: ROLE=all|frontend|backend
.nvmrc pins Node 22 (matches langgraph.json node_version)
docker-compose.yml sets ROLE=all so the container runs both processes
under concurrently:
- Next.js on
:3000 - LangGraph API server on
:2024
The frontend / backend roles exist so the image can be reused for
independent scaling if a deploy target ever needs it. Override via env
when launching:
docker run -e ROLE=frontend -p 3000:3000 langgraph-app
docker run -e ROLE=backend -p 2024:2024 langgraph-appCode-level separation lives in scripts/start.sh — each role is a
discrete function, easy to read and audit.
The base image is langchain/langgraphjs-api:22 — the official LangGraph
prod runtime (Python uvicorn + Go core). It runs the compiled graphs
with LANGGRAPH_RUNTIME_EDITION=postgres, which honors
backend/checkpointer.ts's PostgresSaver. Thread state persists
across container restarts.
Two runtime requirements the base image enforces:
- Redis on
$REDIS_URI— required at startup (FF_USE_REDIS_QUEUE=falsedoesn't bypass the connection check). The compose stack includes aredis:7-alpineservice. - DB migrations — three sources share the same DB:
Drizzle SQL files (
db/migrations/*.sql), langgraphPostgresStore.setup(), langgraphPostgresSaver.setup(). All three are idempotent. The prod container runspnpm db:migrateautomatically insidescripts/start.shbefore forking Next.js + langgraph-api, so a fresh deploy doesn't need a manual migration step (see docs/DEPLOY.md). Don't movesetup()calls back into app module-load — undernext build's N parallel page-data workers,CREATE TABLE IF NOT EXISTSraces onpg_type_typname_nsp_indexand breaks CI deterministically. The CI build + test jobs runpnpm db:migrateas an explicit step beforenext build/vitestbecause those commands bypassstart.sh. - langgraph-api 0.10.x wraps
CREATE INDEX CONCURRENTLY ... store_prefix_idxin a transaction, which Postgres rejects. Until upstream fixes it, run the workaround once after the first start (see docs/DEPLOY.md):Subsequent restarts skip already-applied migrations.ALTER TABLE store ADD COLUMN IF NOT EXISTS prefix text; UPDATE store SET prefix = namespace_path WHERE prefix IS NULL; CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops); INSERT INTO store_migrations (v) VALUES (29) ON CONFLICT DO NOTHING;
Triggers: push + PR to main and dev. Four parallel jobs:
- lint —
pnpm lint(oxlint + oxfmt --check). - typecheck —
pnpm typecheck(tsc --noEmit). - build —
pnpm build(Next.js production build). Needs a reachable Postgres onlocalhost:5432— Better Auth runs DB migrations at module load, which Next.js triggers when it statically evaluates App Router routes. The job exposes apgvector/pgvector:pg16service for this (stockpostgres:16-alpinedoesn't bundle thevectorextension that KB migration0005_*.sqlneeds;pgvector/pgvector:pg16is wire-compatible with upstream postgres so no other step changes). - test —
pnpm test(Vitest, node + frontend projects). Postgres service container exposeslocalhost:5432;globalSetupapplies Drizzle migrations fromdb/migrations/*.sqlbefore tests run.
Why parallel: faster feedback (lint fails in 30s, doesn't wait for build).
Why each job installs deps separately: cheap, and isolates failures —
a flaky network step in test doesn't poison lint.
Concurrency: cancel-in-progress per ref — re-pushing a branch cancels
the previous in-flight run for that same ref. Different refs run in
parallel.
Secrets / env: none. Test env values are hard-coded — they match
.env.test, which is committed. BETTER_AUTH_SECRET here is a fake
string; the auth tests mock getSession() directly, so the real secret
isn't needed.
Skipped (add when needed):
- Playwright e2e — needs Next + LangGraph + Postgres + a browser.
~5-10 min extra, brittle on first run. Run locally via
pnpm test:e2euntil you actually need the gate. - Action SHA pinning — using
@v4etc. for now. Pin by commit SHA (actions/checkout@<sha>) when supply-chain risk warrants it; Renovate / Dependabot can keep the SHAs current. - Coverage upload — local
coverage/is fine until a dashboard exists.
Triggers: push to main / dev, PR to main / dev (build only),
workflow_dispatch (manual override).
| trigger | tag | channel | pushes? |
|---|---|---|---|
push to main |
YYYY-MM-DD-<sha> + :latest |
stable | yes |
push to dev |
beta-<sha> |
beta | yes |
| push to other branch | <branch>-<sha> |
branch | yes |
| PR (any branch) | (resolved tag) | — | no (build-only sanity check) |
workflow_dispatch |
image_tag input or branch-driven |
manual/auto | yes |
Each main push produces a unique date-versioned tag (so the release
history accumulates) and also re-tags :latest on the same image — so
docker pull ghcr.io/<owner>/langgraph-app:beta gives you the latest dev
build, and :latest follows main. Same-day hotfixes don't collide
because the tag includes the short SHA.
The resolve job isolates the tag logic from the build job — easy to
audit and easy to add new channels later (e.g. release-<version>).
PR builds don't push (and don't even attempt GHCR login — GITHUB_TOKEN
isn't available to fork PRs). The build still runs so the PR page shows
a real Docker build status.
OCI image labels are set on every build (org.opencontainers.image.source,
.revision, .version, .title) — docker inspect exposes them.
provenance: true is set when pushing — enables SLSA-style build
provenance attestation via the GHCR UI.
A separate release job (after build succeeds) runs on stable + beta
channels:
- Pulls the just-built image from GHCR.
- Saves it to
langgraph-app-<tag>.tar.gz. - Creates / updates a GitHub Release via
softprops/action-gh-release@v2, attaching the tarball.
- Stable (main) — new release per push, tag =
YYYY-MM-DD-<sha>.softprops/action-gh-release@v2defaults tomake_latest: trueon non-prereleases, so the most recent stable release is auto-marked as "Latest" in the GitHub UI; older stable releases stay visible as versioned history. Tarball asset is unique per tag. - Beta (dev) — new release per push, tag =
beta-<sha>, marked prerelease. - Other branches — skipped.
Why both GHCR + GitHub Release? GHCR is the canonical registry but
needs docker login to pull. The release tarball lets you fetch + load
an image with just curl + docker load — useful for self-hosted
deploys without GHCR credentials, and for air-gapped environments.
Concurrency: split by event_name. PR runs use
cancel-in-progress: true (separate group via event_name in the key)
— they're Docker sanity checks only, no push, no release, no
side-effects to clean up. push events (main/dev) keep
cancel-in-progress: false — an in-flight image push or GitHub
Release should finish rather than leave a half-published artifact.
- Deploy step. Pull the image onto the host and restart, or wire up
appleboy/ssh-action,kubernetes/kubectl-action,superfly/flyctl-actions, etc. depending on the target. - DB migration. If
db:migrateneeds to run as part of a release, add it as a step before the restart (e.g. runpnpm db:migrateinside the container against the prod DB). - Environment protection rules. GitHub Environments (
production,beta) give you manual approval gates + per-env secrets — set them up under Settings → Environments when the deploy step is wired in.
.github/ISSUE_TEMPLATE/:
bug_report.yml— structured bug intake with severity dropdown, repro steps, self-checks.feature_request.yml— problem-first framing, scope + risk dropdowns, self-checks.config.yml—blank_issues_enabled: true+ contact links to Discussions anddocs/so questions redirect away from Issues.
With Docker / OrbStack:
docker compose config --quiet # validates compose + env interpolation
docker compose build app # builds the image; needs a reachable Postgres
# at localhost:5432 — Better Auth runs DB
# migrations during `next build`. On Mac,
# start one in another terminal first:
docker run -d --name build-pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=langgraph_app \
pgvector/pgvector:pg16
docker compose build app
docker rm -f build-pgWhy the Postgres dance? next build statically evaluates App Router
routes; Better Auth's runStoreMigrations runs at module load against
DATABASE_URL. The CI/CD build jobs expose a postgres service so
this works automatically. Locally, spin one up as above. The clean
long-term fix is making Better Auth init lazy — out of scope for this
PR.
With act (GitHub Actions runner locally):
brew install act # if not installed
act -j lint # cheapest job (~30s)
act -j build # ~1 min
# build + test jobs need services; act handles them via docker, but
# a local postgres is faster:
pnpm test- Bump Node — bump
.nvmrc,langgraph.jsonnode_version, and thelangchain/langgraphjs-apibase tag in theDockerfile. - Add a CI job — append a new entry under
jobs:inCI.yml. - Add a CD channel — extend the
resolvejob's branch logic; the build job picks the tag up automatically. - Change deploy target — extend
CD.ymlafter thebuildjob. Don't remove the GHCR push — it's the artifact store that any deploy step pulls from.
Both CI and CD cache pnpm install output, keyed on the lockfile:
- CI —
actions/setup-nodewithcache: pnpmcaches the~/.local/share/pnpm/storedirectory to the GitHub Actions cache. Each job restores it beforepnpm install --frozen-lockfile, so only the link step runs. - CD — the Dockerfile's
pnpm installstep uses a BuildKit--mount=type=cachefor the same store path. Thebuild-push-actionwrites the cache to GHA (cache-to: type=gha,mode=max) and restores it on the next run (cache-from: type=gha). The Docker layer that containsnode_modulesis also cached as a regular layer.
When pnpm-lock.yaml doesn't change between runs, both cache paths
hit and pnpm install skips downloading entirely. When it does
change, only the diff is fetched — the rest stays warm.
Cold-start (no cache, fresh runner): ~60s for pnpm install. Warm
cache: ~5s.