Skip to content

Scaffolding a service for mctl deploy

When mctl_deploy_service action=onboard runs against your repo, mctl expects three artifacts to already be in place:

  1. Dockerfile at the repo root (or at the configured dockerfile_path)
  2. (For auto-deploy on push to main) .github/workflows/ci.yml containing a deploy job
  3. A MCTL_GITHUB_TOKEN GitHub Actions secret — a classic GitHub PAT with scope read:user, used by the deploy job to authenticate to api.mctl.ai

If your repo is missing any of these, copy the canonical templates below. They cover the common languages and produce small (~50–150 MB), non-root images with sane health-check semantics.

Pick a template by language

Repo containsTemplate
package.json, server.js / index.jsNode.js
pyproject.toml or requirements.txtPython
go.modGo
Static SPA (Vue / React / SvelteKit dist/)Static

Node.js

dockerfile
FROM node:22.11-alpine3.20

RUN apk add --no-cache tini

ENV NODE_ENV=production \
    PORT=8787

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

RUN addgroup -S app && adduser -S -G app app && chown -R app:app /app

USER app

EXPOSE 8787

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "require('http').get('http://127.0.0.1:8787/healthz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Implement a GET /healthz endpoint returning 200 {"ok": true} so the HEALTHCHECK passes. Can't use /healthz? Pass health_check_path=/your-path when onboarding to override both liveness and readiness probe paths — but note that only changes the Kubernetes probes, not this Dockerfile's own HEALTHCHECK line. Update the HEALTHCHECK (and any later verification curl) to the same custom path too, or the image will report unhealthy in any environment that runs Docker's own check (e.g. local docker run) even though the platform reports it healthy. If your service uses a different entrypoint (index.js, dist/main.js), update the final CMD.

Python

dockerfile
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml requirements*.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY --from=builder /root/.local /home/app/.local
COPY . .

RUN useradd -m app && chown -R app:app /app

USER app
ENV PATH=/home/app/.local/bin:$PATH \
    PORT=8000

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8000/healthz || exit 1

ENTRYPOINT ["tini", "--"]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Replace app:app with your ASGI module path (e.g., my_service.main:app). For non-uvicorn workloads, swap the CMD for your runner.

Go

dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags='-s -w' -o /app/server ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache tini ca-certificates
COPY --from=builder /app/server /app/server
RUN addgroup -S app && adduser -S -G app app
USER app
ENV PORT=8080
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/server"]

Adjust ./cmd/server to your binary's package path.

Static (Vue / React build → nginx)

dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
RUN apk add --no-cache tini
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/ || exit 1

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["nginx", "-g", "daemon off;"]

Minimal nginx.conf next to your Dockerfile:

nginx
server {
  listen 8080;
  root /usr/share/nginx/html;
  index index.html;
  location / {
    try_files $uri $uri/ /index.html;
  }
}

The SPA fallback (try_files … /index.html) lets client-side routing work without 404s.

When does the deploy fire?

mctl doesn't watch your repo on its own — your CI workflow is what calls mctl_deploy_service when you want a deploy. Pick the trigger that matches your release model.

PatternTriggerWhen it fits
Continuous deploymenton: push to main (recommended for one-environment services)Single env, tags auto-bumped, fast feedback. Every merge ships.
Tag-basedon: push of tags matching *.*.* (and skip the auto-bump step)You want to decide what's a release. Push a tag manually with git tag X.Y.Z && git push --tags.
Manualon: workflow_dispatchFull human control. No automation. Useful for fragile / regulated services.
HybridPush to main runs validation only; tag push does the deployMulti-environment (e.g. staging vs prod) or when validation should diverge from release cadence.

The snippet below implements the continuous deployment pattern: every push to main is auto-tagged with the next SemVer patch and triggers mctl_deploy_service. To switch to tag-based, change the if: to startsWith(github.ref, 'refs/tags/'), drop the Compute next SemVer patch and Push new tag steps, and pass ${{ github.ref_name }} as git_tag in the curl payload.

Run a docker build job on PRs only as a pre-merge gate against broken Dockerfiles. On push to main, mctl rebuilds the same image centrally — duplicating that locally just burns CI minutes. Pattern:

yaml
docker:
  name: docker image build (pre-merge gate)
  if: github.event_name == 'pull_request'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: docker/setup-buildx-action@v3
    - uses: docker/build-push-action@v6
      with:
        context: .
        push: false
        load: false
        tags: <service>:ci
        cache-from: type=gha
        cache-to: type=gha,mode=max

Important: because this job is skipped on push, it cannot appear in the deploy job's needs: list — a skipped dependency would skip deploy too. Only list jobs that actually run on the deploy trigger (typically lint, type-check, test).

CI auto-deploy job

Drop this deploy job into .github/workflows/ci.yml, after your existing build / lint / test jobs. The needs: list should reference jobs that actually run on push to main — not the PR-only docker build above. Replace <team>, <service>, <owner>/<repo>, and the needs: job name.

yaml
deploy:
  name: deploy to mctl <team>/<service>
  needs: [<your-build-job>]
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  permissions:
    contents: write
  steps:
    - name: Skip if MCTL_GITHUB_TOKEN unset
      id: gate
      run: |
        if [ -z "${{ secrets.MCTL_GITHUB_TOKEN }}" ]; then
          echo "::warning::MCTL_GITHUB_TOKEN secret not set — skipping deploy"
          echo "skip=true" >> "$GITHUB_OUTPUT"
        else
          echo "skip=false" >> "$GITHUB_OUTPUT"
        fi

    - uses: actions/checkout@v4
      if: steps.gate.outputs.skip != 'true'
      with:
        fetch-depth: 0

    - name: Compute next SemVer patch
      id: tag
      if: steps.gate.outputs.skip != 'true'
      run: |
        set -euo pipefail
        LAST=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
        MAJOR_MINOR=$(echo "$LAST" | cut -d. -f1,2)
        PATCH=$(echo "$LAST" | cut -d. -f3)
        echo "tag=${MAJOR_MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT"

    - name: Push new tag
      if: steps.gate.outputs.skip != 'true'
      run: |
        git config user.name "github-actions[bot]"
        git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
        git tag -a "${{ steps.tag.outputs.tag }}" \
          -m "Auto-deploy ${{ steps.tag.outputs.tag }} from ${{ github.sha }}"
        git push origin "${{ steps.tag.outputs.tag }}"

    - name: Trigger mctl deploy-service
      if: steps.gate.outputs.skip != 'true'
      run: |
        set -euo pipefail
        curl -fsS -X POST https://api.mctl.ai/api/v1/operations/deploy-service/execute \
          -H "Authorization: Bearer ${{ secrets.MCTL_GITHUB_TOKEN }}" \
          -H "Content-Type: application/json" \
          -d '{
            "action": "deploy",
            "team_name": "<team>",
            "component_name": "<service>",
            "dockerfile_repo": "<owner>/<repo>",
            "git_tag": "${{ steps.tag.outputs.tag }}"
          }'

The gate step makes the job a no-op (with a warning) if the secret isn't configured yet, so onboarding doesn't fail CI before the secret exists.

First-time onboard checklist

  1. Add files — copy the right Dockerfile and the deploy job above.

  2. Create PAThttps://github.com/settings/tokens/new (classic), scope read:user. Save as repo secret named MCTL_GITHUB_TOKEN.

  3. Grant access if mctl can't see the repo:

    mctl_grant_repo_access(team_name="<team>", repo="<owner>/<repo>")

    Open the URL it returns, install the GitHub App, then run mctl_sync_repos(team="<team>").

  4. Onboard:

    mctl_deploy_service(
      action="onboard",
      team_name="<team>",
      component_name="<service>",
      dockerfile_repo="<owner>/<repo>",
      git_tag="0.1.0",
      port="<container port>",
      service_template="default"
    )
  5. Verify — once mctl reports the workflow Succeeded:

    curl https://<team>-<service>.mctl.ai/healthz

    If you passed health_check_path to override the platform probe path, curl that path instead — and update the Dockerfile's HEALTHCHECK line to match too. health_check_path only changes the Kubernetes liveness/ readiness probes; it can't rewrite the image's own HEALTHCHECK, so an app exposing only the custom path will still fail Docker's own health check even though the platform reports it healthy.

    Important note on Authentication: Kubernetes liveness and readiness probes do not send authorization headers. If your service uses authentication middleware or hooks, you must explicitly bypass auth for your health check endpoints (e.g. /healthz, /readyz). Failure to do so will result in 401 Unauthorized responses for K8s probes, causing Kubernetes to endlessly restart and kill your pod (SIGTERM) during deployment.

  6. Push the next commit — CI auto-bumps to 0.1.1 and deploys without human intervention from then on.

Reference implementation

mashkoffdmitry/pelican-libertex-social runs exactly this pattern end-to-end: Node.js Dockerfile, the deploy job in .github/workflows/ci.yml, MCTL_GITHUB_TOKEN secret, deployed as labs/pelican-proxy at https://labs-pelican-proxy.mctl.ai. Browse the repo for a working production example.