Deploying an lt Fullstack Project to TurboOps
This skill is the single source of truth for taking a lenne.tech fullstack
monorepo (projects/api + projects/app, created by lt fullstack init) from
local development to a live deployment on TurboOps (turbo-ops.de) through
GitLab CI/CD.
The CI/CD path itself is turnkey — the starters ship a working .gitlab-ci.yml,
docker-compose.yml, and build-drift wiring. The one thing that reliably
breaks a fullstack rollout is whether turbo deploy actually uploads the
docker-compose.yml. If it doesn't, TurboOps has no service list for the
stage, falls back to a synthesised single-service deploy with a wrong image
reference, and the rollout comes up red (only app rolled out / not found in registry). The fix is one flag on the CI deploy command — no web UI required.
Read the Root Cause + Fix
section before you touch a stage — it is the reason this skill exists.
Use placeholders throughout:
<slug>= TurboOps project slug (matches the repo'spackage.jsonname /lt devslug),<stack>= the deployed swarm stack name (<slug>-<stageSlug>, e.g.<slug>-production),<db>= the Mongo database name.
When to Use This Skill
| Situation | Use this skill? |
|---|---|
| First-time go-live of an lt fullstack project on TurboOps | Yes |
Adding a dev or production stage to an existing TurboOps project |
Yes |
| A deploy rolled out only the App (api/mongo missing, health check red) | Yes — see Root Cause + Fix |
A deploy fails with not found in registry |
Yes — see Root Cause + Fix |
| Running the app locally under stable HTTPS URLs | No → using-lt-cli (lt dev) |
| Reproducing the CI pipeline on your machine before push | No → validating-ci-pipelines-locally |
| Authoring/refactoring the Docker or CI config itself | No → lt-dev:devops agent |
The Deploy Contract (what a working lt project already has)
An lt fullstack init project deploys as three swarm services behind the
TurboOps reverse proxy. The pieces are wired together by one commit SHA that
flows end to end (this is also the build-drift detector):
CI commit SHA → IMAGE_TAG (.gitlab-ci.yml)
→ APP_VERSION_COMMIT build arg (docker-compose.yml)
→ ENV baked into each image (Dockerfile)
→ GET /meta (api) + runtimeConfig.public.appCommit (app)
.turboops.json (repo root)
Generated by lt deployment create. It is the link between the repo and the
TurboOps project:
{ "project": "<slug>" }
.gitlab-ci.yml — three stages
stages:
- test # lint, api:test, app:test (Playwright E2E), app unit tests, build, audit
- turboops-build # docker compose build + push of api & app images
- deploy # turbo deploy <stageSlug> --wait, branch-gated
turboops-build—docker compose -f docker-compose.yml build api app, thendocker compose -f docker-compose.yml push api apptoregistry.turbo-ops.de/<slug>. Runs aftertestis green.deploy-dev/deploy-prod—turbo deploy <stageSlug> --compose docker-compose.yml --wait, gated by branch:deploy-devonly: [dev],deploy-prodonly: [main]. The--compose docker-compose.ymlflag is what makes the stage multi-service — it uploads the compose so all three services register (see Root Cause + Fix). This CI job is the supported way to roll a pipeline stage.--waitblocks the job until the rollout is healthy, so a red health check fails the pipeline.
docker-compose.yml — three services
| Service | Image | Port | Health check |
|---|---|---|---|
mongo |
mongo:7 (named volume for data) |
27017 (internal) | mongo ping |
api |
${IMAGE_NAME}/api:${IMAGE_TAG} |
expose 3000 | GET /health-check |
app |
${IMAGE_NAME}/app:${IMAGE_TAG} |
expose 3000 | GET / |
IMAGE_NAME=registry.turbo-ops.de/${TURBOOPS_PROJECT}IMAGE_TAG=${CI_COMMIT_SHA}APP_VERSION_COMMITbuild arg is set toIMAGE_TAGfor both images → the commit is frozen into the bundle so a stale/partial rollout (one container older than the other) is visible viaGET /meta(commit) and the app's/admin/systemdrift warning.
MongoDB URI — use the full swarm service name
In the deployed stage, api reaches Mongo over the swarm network under the
full stack-qualified service name, not the short mongo:
NSC__MONGOOSE__URI=mongodb://<stack>_mongo:27017/<db>
# e.g. mongodb://<slug>-production_mongo:27017/<db>
The short mongo host does NOT fail loudly — that is what makes it dangerous.
It resolves fine in the deployed swarm. TurboOps puts every stack on a shared
overlay network, and a service literally named mongo exposes that bare name as
a network alias there. So mongodb://mongo:27017/<db> reaches some MongoDB —
just not yours. It lands on whichever foreign stack's mongo answers first, and
that can differ per connection.
Consequences, none of which look like a configuration problem:
- The api boots, is healthy, serves data. Nothing in the logs is wrong.
- Your project's own
<stack>_mongovolume stays completely empty. - Two stacks answering the same alias produce two parallel datasets; requests hit one or the other, so records "appear and disappear" between calls, sessions vanish after a reconnect, and GridFS files are found only half the time.
- Your data sits in another customer's database. This has happened twice in production (DEV-2120, DEV-2140), the second time in a container that also held an unrelated project's production database.
Do not conclude from "the API is up and returns data" that the URI is correct. That inference is exactly what the bare host survives on.
Step-by-Step: First Go-Live
Follow these in order. Steps that must happen in the TurboOps web UI or via DNS are called out explicitly — do not try to substitute an MCP tool for them.
1. Prerequisites
- The
turboopsMCP server is configured in the Claude Code session. It is a server the user adds themselves (claude mcp addor/mcp, user or project scope), not one any lenne.tech plugin bundles — so a fresh machine has it missing rather than broken. Without it, the verification step falls back to the TurboOps web UI pluscurlchecks. - The project builds and its local CI passes — reproduce the pipeline first
with the
validating-ci-pipelines-locallyskill so a red deploy is never a surprise from a failure that had nothing to do with TurboOps. - A TurboOps project exists (create it in the web UI or via the deployment
MCP if you already have a healthy workspace). Note its slug =
<slug>. - The repo is an
lt fullstack initproject with the deploy contract above. Ifdocker-compose.yml/.gitlab-ci.ymlare missing, generate the Docker setup first (see thelt-dev:devopsagent //lt-dev:docker:gen-setup).
2. lt deployment create → .turboops.json
From the repo root, run non-interactively (pass --noConfirm like every lt CLI
call from Claude Code, so it never blocks on a prompt):
lt deployment create
This writes .turboops.json = { "project": "<slug>" } at the repo root.
Commit it — CI reads it.
3. GitLab CI/CD variables
In GitLab → Settings → CI/CD → Variables, add:
| Variable | Value | Flags |
|---|---|---|
TURBOOPS_PROJECT |
<slug> |
plain |
TURBOOPS_TOKEN |
deploy token (see below) | Masked, Protected = false |
TURBOOPS_TOKENcan be minted WITHOUT the web UI (verified 2026-07 in the lt-smoke-test run). After aturbo login(browser flow — needs an active turbo-ops.de session), the CLI API mints a project token:# user token + tenant from ~/Library/Preferences/turboops-cli-nodejs/config.json curl -X POST https://api.turbo-ops.de/cli/deployment/tokens \ -H "Authorization: Bearer $USER_TOKEN" -H "X-Tenant-Id: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{"project":"<projectId>","name":"gitlab-ci"}' # → response.plainToken (shown ONCE) = TURBOOPS_TOKEN; # permissions: deploy, rollback, logs, registryPush, registryPullThen set both CI variables non-interactively:
GITLAB_HOST=<host> glab variable set TURBOOPS_PROJECT <slug> -R <group>/<repo>andecho "$TOKEN" | glab variable set TURBOOPS_TOKEN -R <group>/<repo> --masked. The web UI (Project → Settings → Tokens) remains the manual alternative.Protected = false is required. The
deploy-devjob runs on thedevbranch, which is typically not a protected branch. A protected variable is invisible to unprotected branches, so the dev deploy would fail with a missing token. Keep it masked (so it never prints in job logs) but unprotected.
4. Make the stage multi-service — ensure turbo deploy uploads the compose
A stage becomes multi-service the moment TurboOps receives the repo's
docker-compose.yml: the server's syncServicesFromCompose registers all
three services (mongo, api, app) and derives the domains (app → root
domain, api → api.<root>). The recommended, fully automatic way to get
the compose there is the CI deploy flag — no web UI step required:
turbo deploy <stageSlug> --compose docker-compose.yml --wait
Make sure one of these is true so the compose actually reaches the server (any single one suffices — see Root Cause + Fix):
--compose docker-compose.ymlon the CIturbo deploy— recommended; already set in the current lt-monorepo CI template. Add it if an older project's.gitlab-ci.yml/ GitHubdeploy.ymlomits it.- The project has
detectedConfig.composePathset (fromturbo init/ the web UI's repo detection) — then a bareturbo deployuploads the compose for you (first / non-promote stage only). - The TurboOps web UI — creating/editing the stage there also parses
docker-compose.ymland registers all services. This is just one alternative way to register the compose, not a required manual step. - The CLI API, BEFORE the first deploy (verified 2026-07): a user-token
POST /cli/deployment/projects/<projectId>/composewith{"content": "<docker-compose.yml content>", "message": "..."}syncs all services onto every EXISTING stage immediately — a fully-scripted setup can therefore avoid the single-service first deploy entirely.
Service domains are NOT derived by the compose sync. After registering the
services on an MCP/API-created stage, set the api service's hostname explicitly
(the app is served via the stage-root primaryDomain; setting the same hostname
on the app service is rejected as a collision):
update_service_domain(stageId, serviceName: "api", primary: "api.<root-domain>")
Without this, the deploy generates no route for api.<root> even though all
containers come up healthy.
Then:
- Stage slug convention:
dev(from branchdev) andproduction(from branchmain). The slug must match theturbo deploy <stageSlug>argument in.gitlab-ci.yml. - Set the stage env vars, including the swarm Mongo URI from the contract:
NSC__MONGOOSE__URI=mongodb://<stack>_mongo:27017/<db>(full swarm host — see Trap: MongoDB URI).
A stage that was previously created single-service via the MCP
create_deployment_stage(onlyapprolls out) is fixed the same way: let a CIturbo deploy --compose docker-compose.ymlupload the real compose, which re-registers all services. No MCP tool adds services to an existing stage; the--composeupload is what repairs it. See the Gotchas below.
5. DNS → point the domains at the server (before deploying)
The user creates the DNS records. Both the root and the api. host must resolve
to the TurboOps server before the first deploy:
A/CNAMEfor the root domain (→app)A/CNAMEforapi.<root>(→api)
TurboOps issues Let's Encrypt certificates on first deploy; if DNS is not yet pointing at the server, certificate issuance fails and the stage comes up without valid TLS. Get DNS in place first, confirm it resolves, then deploy.
6. Trigger the deploy via CI
Push to the branch that gates the stage:
devbranch →deploy-devjob →turbo deploy dev --compose docker-compose.yml --waitmainbranch →deploy-prodjob →turbo deploy production --compose docker-compose.yml --wait
The test and turboops-build stages run first; deploy-* runs last and blocks
on --wait until the rollout is healthy. The --compose docker-compose.yml flag
uploads the compose so the stage rolls out all three services — verify it is
present in the deploy command.
7. Verify
- CI: the
deploy-*job is green (it only goes green once the stage reports healthy, thanks to--wait). - Rollout topology: the deploy log shows all three services being
created (
Creating service <stack>_mongo/_api/_app) and reachingX/X containers healthy— not a single loneappline (that is Trap 1). - Health endpoints:
GET https://api.<root>/health-checkis OK; the app root loads over HTTPS with a valid cert. - Build drift: the app's
/admin/systempage shows matching App and API commits (no drift warning).GET https://api.<root>/metacommitequals the deployed CI SHA.
Root Cause + Fix: uploading the compose is what makes a stage multi-service
Both classic fullstack-rollout failures — "only app rolled out" and "not found in registry" — are the same bug seen from two angles, verified in the
TurboOps CLI source (projects/cli/src/commands/deploy.ts ~113-135):
turbo deploy <stage> uploads the repo's docker-compose.yml only when
--compose <path>is passed on the command line, or- the TurboOps project has
detectedConfig.composePathset (and even then only for the first / non-promote stage).
Projects created freshly via the CLI or MCP have no detectedConfig, so a
bare turbo deploy <stage> (no --compose) sends no compose. With no
service list to work from, the server (deployment-orchestration.processor)
falls back to generateFallback, which synthesises a single service with a
hyphen-joined image reference registry.turbo-ops.de/<slug>-<name>:<tag>
instead of the slash-joined registry.turbo-ops.de/<slug>/<name>:<tag> that
CI actually pushed → not found in registry and a 0/1 healthy health check.
When a real compose arrives, the server's syncServicesFromCompose
registers all services (mongo/api/app) and resolves each image
correctly → multi-service, green — with no web-UI step at all.
The fix (fully automatic, no UI)
Append --compose docker-compose.yml to the turbo deploy call in
.gitlab-ci.yml (and the GitHub deploy.yml):
turbo deploy <stageSlug> --compose docker-compose.yml --wait
Every CI deploy then uploads the compose → all services register and images resolve → the stage is multi-service and green, no UI. This is maintained in the lt-monorepo CI template; if an older project omits the flag, add it.
Any one of these registers the compose — pick whichever fits:
- CLI
--compose docker-compose.ymlin CI — recommended; the fix above. - A project with
detectedConfig.composePathset (fromturbo init/ the web UI's repo detection) — then a bareturbo deployuploads the compose for you (first / non-promote stage only). - The TurboOps web UI — creating/editing the stage there parses the compose and registers all services. One alternative, not a mandatory manual step.
Symptom → diagnosis
- Deploy log shows a single
Creating service <stack>_appline (no_api/_mongo), or Image registry.turbo-ops.de/<slug>-<sha>:<sha> not found in registry(note the hyphen and the missing/api|/appsuffix — thegenerateFallbacksignature).
Either symptom means no real compose reached the server → --compose is
missing from the CI deploy command (or the project has no detectedConfig). Add
--compose docker-compose.yml and re-run the deploy job.
Symptom → diagnosis: data that will not hold still
These all have one cause and it is never the application code. If you see any of them, check the DB host before debugging anything else (Trap 3):
| Symptom | What is actually happening |
|---|---|
| A record exists on one request and is gone on the next | Two stacks answer the same mongo alias — you are talking to two databases in turn |
| Users are logged out at random | The session was written to one instance, the next connection reads the other |
| An image/file 404s or 502s while its DB record looks perfect | The GridFS chunks live in the other instance |
| The seed ran twice and produced two datasets with different IDs | Same |
| Everything works, but your stage's own mongo volume is empty | You have never used your own database |
One command settles it — if the api's mongo and the stack's mongo are different hosts, that is the bug:
docker exec $(docker ps -q -f name=<stack>_api | head -1) \
sh -c 'getent hosts mongo; getent hosts <stack>_mongo'
A 502 from a single route while every other route answers is a different
bug and not this one: it is an unhandled error on a streamed response (e.g.
stream.pipe(res) with no error handler), which destroys the socket
mid-response. The proxy reports a gateway error, so it reads as "server down"
while the server is fine. Look for a missing stream error handler, not at
infrastructure.
Gotchas / Traps
Five traps account for nearly every failed go-live on this stack: a stage that stays single-service, an image the registry never received, a certificate issued before DNS pointed anywhere, a MongoDB URI that works locally but not in the swarm, and a rollout that reports healthy while serving the previous build.
Each one, with its symptom, its cause, and its fix: reference/traps.md. Read it before the first deploy of a project, and again whenever a deploy behaves in a way the steps above do not explain.
Related Skills
using-lt-cli—lt deployment create,lt devlocal orchestration, and the general lt CLI reference (--noConfirmrule,lt fullstack init).validating-ci-pipelines-locally— reproduce the GitLab/GitHub pipeline locally before pushing, so a red deploy is never caused by a CI failure unrelated to TurboOps.validating-production-readiness— the eight-pillar release gate to run before a production go-live.lt-dev:devops(agent) — author/refactor thedocker-compose.ymland.gitlab-ci.ymlthemselves when the deploy contract is missing or drifted.