# Deploying To Turboops

> Takes a lenne.tech fullstack monorepo live on TurboOps (turbo-ops.de) via GitLab CI/CD. Covers the deploy contract (.turboops.json, .gitlab-ci.yml, docker-compose.yml, image drift), `lt deployment create`, CI/CD variables, multi-service stages via `turbo deploy --compose`, DNS-before-Let's-Encrypt, and the swarm MongoDB URI. Activates on "TurboOps", "turbo deploy", "live gehen", "deployen", and on symptoms like "not found in registry" or "only app rolled out". NOT for local dev orchestration (use using-lt-cli). NOT for reproducing CI locally (use validating-ci-pipelines-locally). NOT for authoring Docker/CI configs from scratch (use the devops agent).

- Skill: `lennetech/deploying-to-turboops` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add lennetech/deploying-to-turboops`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lennetech/deploying-to-turboops/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: lenneTech (https://skillmd.com/u/lennetech)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lennetech/deploying-to-turboops

---


# 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](#root-cause--fix-uploading-the-compose-is-what-makes-a-stage-multi-service)
section before you touch a stage — it is the reason this skill exists.

> Use placeholders throughout: `<slug>` = TurboOps project slug (matches the
> repo's `package.json` name / `lt dev` slug), `<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:

```json
{ "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`,
  then `docker compose -f docker-compose.yml push api app` to
  `registry.turbo-ops.de/<slug>`. Runs after `test` is green.
- **`deploy-dev` / `deploy-prod`** — `turbo deploy <stageSlug> --compose
  docker-compose.yml --wait`, gated by branch: `deploy-dev` `only: [dev]`,
  `deploy-prod` `only: [main]`. The **`--compose docker-compose.yml`** flag is
  what makes the stage multi-service — it uploads the compose so all three
  services register (see [Root Cause + Fix](#root-cause--fix-uploading-the-compose-is-what-makes-a-stage-multi-service)).
  This CI job is the supported way to roll a pipeline stage. `--wait` blocks 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_COMMIT` build arg is set to `IMAGE_TAG` for **both** images →
  the commit is frozen into the bundle so a stale/partial rollout (one container
  older than the other) is visible via `GET /meta` (`commit`) and the app's
  `/admin/system` drift 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>_mongo` volume 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 **`turboops` MCP server is configured** in the Claude Code session. It is a server the user
  adds themselves (`claude mcp add` or `/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 plus `curl` checks.
- The project builds and its **local CI passes** — reproduce the pipeline first
  with the `validating-ci-pipelines-locally` skill 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 init` project with the deploy contract above. If
  `docker-compose.yml` / `.gitlab-ci.yml` are missing, generate the Docker setup
  first (see the `lt-dev:devops` agent / `/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):

```bash
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_TOKEN` can be minted WITHOUT the web UI** (verified 2026-07 in the
  lt-smoke-test run). After a `turbo login` (browser flow — needs an active
  turbo-ops.de session), the CLI API mints a project token:

  ```bash
  # 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, registryPull
  ```

  Then set both CI variables non-interactively:
  `GITLAB_HOST=<host> glab variable set TURBOOPS_PROJECT <slug> -R <group>/<repo>`
  and `echo "$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-dev` job runs on the `dev`
  branch, 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](#root-cause--fix-uploading-the-compose-is-what-makes-a-stage-multi-service)):

- **`--compose docker-compose.yml` on the CI `turbo deploy`** — recommended;
  already set in the current lt-monorepo CI template. Add it if an older
  project's `.gitlab-ci.yml` / GitHub `deploy.yml` omits it.
- **The project has `detectedConfig.composePath` set** (from `turbo init` /
  the web UI's repo detection) — then a bare `turbo deploy` uploads the compose
  for you (first / non-promote stage only).
- **The TurboOps web UI** — creating/editing the stage there also parses
  `docker-compose.yml` and 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>/compose` with
  `{"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 branch `dev`) and `production` (from branch
  `main`). The slug must match the `turbo 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` (only `app` rolls out) is fixed the same way: let a
> CI `turbo deploy --compose docker-compose.yml` upload the real compose, which
> re-registers all services. No MCP tool adds services to an existing stage; the
> `--compose` upload 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`/`CNAME` for the root domain (→ `app`)
- `A`/`CNAME` for `api.<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:

- `dev` branch → `deploy-dev` job → `turbo deploy dev --compose docker-compose.yml --wait`
- `main` branch → `deploy-prod` job → `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 reaching
  `X/X containers healthy` — not a single lone `app` line (that is Trap 1).
- **Health endpoints:** `GET https://api.<root>/health-check` is OK; the app
  root loads over HTTPS with a valid cert.
- **Build drift:** the app's `/admin/system` page shows matching App and API
  commits (no drift warning). `GET https://api.<root>/meta` `commit` equals 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.composePath` set (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.yml` in CI** — recommended; the fix above.
- **A project with `detectedConfig.composePath` set** (from `turbo init` / the
  web UI's repo detection) — then a bare `turbo deploy` uploads 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>_app` line (no `_api` /
  `_mongo`), **or**
- `Image registry.turbo-ops.de/<slug>-<sha>:<sha> not found in registry` (note
  the **hyphen** and the missing `/api`|`/app` suffix — the `generateFallback`
  signature).

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:

```bash
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](${CLAUDE_SKILL_DIR}/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 dev` local orchestration, and the
  general lt CLI reference (`--noConfirm` rule, `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 the `docker-compose.yml` and
  `.gitlab-ci.yml` themselves when the deploy contract is missing or drifted.

