Guide · CI/CD

MCP Servers with GitLab CI/CD — pipeline stages, container registry, and protocol verification

GitLab CI/CD offers a tightly integrated experience: your repository, container registry, pipeline definitions, environments, and deployment history all live under one roof. For MCP server teams that already host code on GitLab, this eliminates the credential plumbing required to connect a separate CI system to a separate registry. This guide walks through a production-grade .gitlab-ci.yml for an MCP server — covering the three-stage test → build → deploy pipeline, GitLab Container Registry push, protected environments, Docker-in-Docker caveats, and a post-deploy protocol verification step that catches broken deployments before your users do.

TL;DR

Define a three-stage pipeline in .gitlab-ci.yml (test → build → deploy), push your image to registry.gitlab.com/$CI_PROJECT_PATH using the built-in CI_REGISTRY_* variables, restrict deploy jobs to protected branches with rules:, and finish every deploy job with a curl MCP protocol probe plus an AliveMCP registration call so monitoring is always current with the running server.

Pipeline structure and stages

GitLab CI/CD pipelines are defined in a single .gitlab-ci.yml at the repository root. The top-level stages: key sets the execution order; jobs assigned to each stage run in parallel with other jobs in the same stage, and the next stage only starts after all jobs in the current stage succeed. For an MCP server the natural three stages are test, build, and deploy.

The test stage runs unit tests, integration tests, and any static analysis in an ephemeral container — no Docker daemon needed, just a Node.js or Python image. The build stage constructs and pushes the Docker image to the GitLab Container Registry. The deploy stage pulls the image on the target host (VPS via SSH, or a Kubernetes cluster via kubectl) and then probes the MCP protocol endpoint to verify the server is actually serving valid responses after the new image is live.

The key GitLab-specific advantage here is that the Container Registry URL is always registry.gitlab.com/<group>/<project> and the push credentials are injected automatically as CI_REGISTRY, CI_REGISTRY_USER, and CI_REGISTRY_PASSWORD. You do not need to create a service account or an API token for the registry — GitLab handles authentication with a short-lived token scoped to the current job. The full image reference is available in the built-in CI_REGISTRY_IMAGE variable, and you can append a tag such as :$CI_COMMIT_SHORT_SHA for immutable image references that map back to a specific commit.

GitLab's built-in variables cover most of what you need: CI_COMMIT_SHORT_SHA for image tags, CI_COMMIT_REF_NAME for the branch name, CI_PIPELINE_SOURCE to distinguish merge request pipelines from branch pipelines, and CI_ENVIRONMENT_NAME to know which environment a deploy job is targeting. This reduces the amount of shell scripting required inside job scripts compared to CI systems that provide fewer built-ins.

CI variables: protected, masked, and unprotected

GitLab CI variables live at the project level under Settings → CI/CD → Variables. Each variable has two important boolean flags: Protected and Masked. Understanding the difference is critical for MCP server security.

A protected variable is only exposed to jobs running on protected branches or protected tags. In practice, this means your SSH_DEPLOY_KEY and ALIVEMCP_API_KEY variables should be marked protected — they will only be available in the pipeline when the pipeline is triggered by a push to main (or another branch you have designated as protected in Settings → Repository → Protected Branches). A merge request pipeline from a fork or a feature branch will not receive these variables, which prevents a malicious contributor from exfiltrating secrets by adding a - echo $SSH_DEPLOY_KEY step to the pipeline.

A masked variable has its value redacted from job logs. GitLab will replace any occurrence of the variable's value in the log output with [MASKED]. You should mark every secret as both protected and masked. The masking only applies when the value matches the variable — it is not a blanket log filter — so you still need to avoid set -x or env commands in your job scripts that would print all environment variables.

Unprotected variables are available in every pipeline regardless of branch — appropriate for non-secret configuration like MCP_SERVER_PORT=3000 or NODE_ENV=production that you want visible in feature branch pipelines for debugging. Never put secrets in unprotected variables.

GitLab Environments, deployment jobs, and protected environments

A GitLab Environment represents a deployment target — typically staging and production. When a job includes an environment: block, GitLab records the deployment in the Environments view, tracks which commit is currently deployed, and provides a direct link to the running application. For MCP servers exposed via a public URL, setting url: https://your-mcp-server.example.com lets you open the server directly from the GitLab pipeline UI.

The protected environment feature (available in GitLab Premium and above) restricts which users can trigger deployments to a given environment. When production is a protected environment, only specific users or roles can manually trigger or approve the deploy job — even if they have push access to the protected branch. This is the GitLab equivalent of GitHub's Environment protection rules. For self-hosted GitLab on the free tier, you can approximate this with when: manual on the production deploy job, which requires a human to click "run" in the pipeline UI rather than it executing automatically.

Ephemeral review environments — one per merge request — are supported with the on_stop: key. You define a stop job that tears down the review environment and link it from the deploy job: on_stop: stop-review. GitLab will offer a "stop environment" button in the merge request view and will automatically run the stop job when the MR is merged or closed. For MCP server teams using Kubernetes, this typically means deleting the review Deployment and Service objects; for VPS-based deploys it might mean running a script to kill the Docker container for that review branch.

The rules: syntax controls when jobs run. Unlike the older only:/except: syntax, rules: supports full boolean conditions and can set job variables per rule. The idiom rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' creates a job that only runs in merge request pipelines, while rules: - if: '$CI_COMMIT_BRANCH == "main"' restricts a job to the main branch. Combining these with when: manual or when: on_success gives you fine-grained control over which pipeline events trigger which jobs.

Docker-in-Docker runners and building the image

Building a Docker image inside a GitLab CI job requires access to a Docker daemon. GitLab supports this through Docker-in-Docker (DinD): you add docker:dind as a service, set the DOCKER_HOST variable, and your job script gains access to a Docker daemon running inside the job's container. The critical configuration caveat is DOCKER_TLS_CERTDIR: in recent versions of the docker:dind image, TLS is enabled by default. You must either set DOCKER_TLS_CERTDIR: "/certs" (and mount the certs volume) or disable TLS entirely with DOCKER_TLS_CERTDIR: "" and set DOCKER_HOST: tcp://docker:2375. The TLS approach is more secure and recommended for production pipelines.

GitLab SaaS shared runners run in the docker executor and already have access to a Docker daemon — you do not need to configure privileged mode for shared runners. Self-hosted runners configured with the docker executor need privileged = true in the runner's config.toml to enable Docker-in-Docker. Alternatively, using the shell executor on a runner host that already has Docker installed avoids the DinD complexity entirely, at the cost of some isolation.

An alternative to DinD is kaniko, which builds OCI images without requiring a Docker daemon at all. Kaniko runs as a regular container, reads a Dockerfile, and pushes the result directly to a registry. For GitLab CI with shared runners, kaniko avoids the privileged mode requirement entirely. The trade-off is that kaniko cache management is less mature than BuildKit's layer cache, and some Dockerfile directives (particularly complex ARG interactions) can behave differently. For most MCP server builds with a standard Node.js or Python Dockerfile, kaniko is a reliable choice.

Complete .gitlab-ci.yml example

The following pipeline implements the full test → build → deploy flow for an MCP server, including MCP protocol verification and AliveMCP registration. Read the inline comments for the rationale behind each configuration choice.

variables:
  # Image tag: immutable, maps back to the exact commit
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  # Also tag as "latest" for the branch for convenience
  IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
  # Docker TLS: required when using docker:dind with TLS enabled
  DOCKER_TLS_CERTDIR: "/certs"
  # MCP server port (non-secret, unprotected variable)
  MCP_PORT: "3000"

stages:
  - test
  - build
  - deploy

# ─────────────────────────────────────────────────────────────
# TEST STAGE
# ─────────────────────────────────────────────────────────────
unit-test:
  stage: test
  image: node:20-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  script:
    - npm ci --prefer-offline
    - npm run lint
    - npm test -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  rules:
    # Run on MR pipelines and on every branch push
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH'

# ─────────────────────────────────────────────────────────────
# BUILD STAGE
# ─────────────────────────────────────────────────────────────
build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    # Authenticate to the GitLab Container Registry with the built-in token
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - |
      docker build \
        --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
        --build-arg GIT_SHA=$CI_COMMIT_SHORT_SHA \
        --cache-from $IMAGE_LATEST \
        --tag $IMAGE_TAG \
        --tag $IMAGE_LATEST \
        .
    - docker push $IMAGE_TAG
    - docker push $IMAGE_LATEST
  rules:
    # Only build on main branch and tags, not on every MR
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_TAG'

# ─────────────────────────────────────────────────────────────
# DEPLOY STAGE
# ─────────────────────────────────────────────────────────────
deploy-production:
  stage: deploy
  image: alpine:3.19
  environment:
    name: production
    url: https://mcp.example.com
    # on_stop links to the teardown job for ephemeral environments
    # on_stop: stop-production  # uncomment if using ephemeral environments
  before_script:
    - apk add --no-cache openssh-client curl jq
    - mkdir -p ~/.ssh
    # SSH_DEPLOY_KEY is a protected+masked CI variable: private key, no passphrase
    - echo "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
    - chmod 600 ~/.ssh/id_ed25519
    - echo "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts  # host fingerprint, pre-collected
    - chmod 644 ~/.ssh/known_hosts
  script:
    # Pull the new image on the production host and restart the container
    - |
      ssh -i ~/.ssh/id_ed25519 deploy@$DEPLOY_HOST << ENDSSH
        set -euo pipefail
        docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        docker pull ${IMAGE_TAG}
        docker stop mcp-server || true
        docker rm mcp-server || true
        docker run -d \
          --name mcp-server \
          --restart unless-stopped \
          -p ${MCP_PORT}:${MCP_PORT} \
          -e NODE_ENV=production \
          -e DATABASE_URL="$DATABASE_URL" \
          ${IMAGE_TAG}
        # Wait for the container to be accepting connections
        sleep 5
      ENDSSH

    # ── MCP Protocol Verification ──────────────────────────────
    # POST initialize to the MCP endpoint and verify protocolVersion
    - |
      RESPONSE=$(curl -sf \
        --max-time 15 \
        -X POST "https://mcp.example.com/mcp" \
        -H "Content-Type: application/json" \
        -d '{
          "jsonrpc": "2.0",
          "id": 1,
          "method": "initialize",
          "params": {
            "protocolVersion": "2025-03-26",
            "capabilities": {},
            "clientInfo": { "name": "gitlab-ci-probe", "version": "1.0" }
          }
        }')
      echo "MCP initialize response: $RESPONSE"
      PROTOCOL_VERSION=$(echo "$RESPONSE" | jq -r '.result.protocolVersion // empty')
      if [ -z "$PROTOCOL_VERSION" ]; then
        echo "ERROR: MCP protocol verification failed — no protocolVersion in response"
        exit 1
      fi
      echo "MCP server verified. Protocol version: $PROTOCOL_VERSION"

    # ── AliveMCP Registration ──────────────────────────────────
    # Register (or re-register) the server so monitoring is current
    - |
      curl -sf \
        -X POST "https://alivemcp.com/api/register" \
        -H "Authorization: Bearer $ALIVEMCP_API_KEY" \
        -H "Content-Type: application/json" \
        -d "{
          \"slug\": \"my-mcp-server\",
          \"url\": \"https://mcp.example.com/mcp\",
          \"label\": \"Production MCP Server\",
          \"deployedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
          \"version\": \"${CI_COMMIT_SHORT_SHA}\"
        }" \
        && echo "AliveMCP registration successful" \
        || echo "WARNING: AliveMCP registration failed (non-fatal)"

  rules:
    # Only deploy from main branch; require manual approval
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
  needs:
    - job: build-image

A few points worth calling out. The needs: key on the deploy job creates a direct dependency on build-image without requiring all other test jobs to have passed — this is a GitLab-specific DAG feature. The when: manual rule means the pipeline pauses at the deploy stage and requires a human to click "run" in the GitLab UI, giving you an approval gate without requiring GitLab Premium's protected environment feature. The MCP probe exits the job with code 1 if the protocol verification fails, which marks the pipeline as failed and prevents AliveMCP from recording a successful deployment against a broken server.

Caching npm dependencies across pipeline runs

GitLab CI caches are keyed by a string and stored on the runner. The example above keys the cache on the hash of package-lock.json, which means the cache is invalidated whenever dependencies change but shared between all runs on the same lockfile. GitLab's cache mechanism is separate from pipeline artifacts: a cache persists across pipeline runs for the same key, while an artifact passes files between stages within the same pipeline run.

For npm, the correct paths to cache are node_modules/ (for the installed packages) or ~/.npm (for the npm package cache). Caching node_modules/ directly is faster for subsequent jobs that run npm ci with --prefer-offline because the packages are already extracted. For runners with distributed storage or S3-backed caches, the download of the extracted node_modules/ tarball can itself take significant time, at which point caching ~/.npm and running npm ci fresh may be comparable. Benchmark both approaches in your specific runner environment.

Frequently asked questions

How do I handle Docker-in-Docker in GitLab CI safely?

The safest configuration uses TLS between the Docker client and the DinD daemon. Set DOCKER_TLS_CERTDIR: "/certs" as a pipeline variable, add docker:dind as a service, and use image: docker:24 for the build job. GitLab's DinD image will generate TLS certificates, mount them at /certs, and the Docker client will automatically pick them up. Do not set DOCKER_HOST manually when using TLS — it will be set to the correct TLS endpoint automatically. For self-hosted runners, the runner's config.toml must have privileged = true in the [runners.docker] section to allow the DinD container to run with the necessary kernel capabilities. If your organisation's security policy prohibits privileged containers, use kaniko as an alternative — it builds images without a Docker daemon by executing Dockerfile instructions directly as a user-space process.

How do I roll back a failed MCP server deployment in GitLab?

GitLab's Environments view tracks every deployment for a given environment, including the image tag and commit that was deployed. To roll back: open Environments → production → click the previous successful deployment → click "Re-deploy". This reruns the deploy job from that pipeline, which will pull the previously-tagged image (e.g., registry.gitlab.com/mygroup/mymcp:abc1234) and restart the container. Because the image pipeline uses immutable CI_COMMIT_SHORT_SHA tags rather than overwriting a mutable :latest tag, every previous build is still available in the registry for instant rollback. You can also trigger a rollback via the API: curl -X POST "https://gitlab.com/api/v4/projects/:id/deployments/:deployment_id/merge_requests" or by re-running the specific pipeline job from the CLI with glab ci retry.

What are the key differences between GitLab CI and GitHub Actions for MCP server deployment?

The most practical difference is that GitLab CI is fully integrated into the platform: the container registry, environments, deployment history, and merge request pipelines are all native features rather than third-party integrations. GitLab's built-in variables (especially CI_REGISTRY_* and CI_ENVIRONMENT_*) reduce the credential wiring needed to connect a registry and a deployment target. GitHub Actions has a larger marketplace of community actions, which means more pre-built steps for common tasks; GitLab's equivalent is the Catalog of CI/CD components (and older "templates"), which is growing but smaller. GitLab's rules: syntax is more expressive than GitHub Actions' if: conditionals for complex branching logic. For Kubernetes deployments, GitLab has a native Kubernetes integration that can configure runner access automatically, whereas GitHub Actions requires manual kubeconfig injection via secrets. Both platforms are fully capable for MCP server CI/CD; the choice usually comes down to where your code is already hosted.

How do I use GitLab Environments to differentiate staging from production deploys?

Define two separate deploy jobs — one targeting environment: name: staging and one targeting environment: name: production. Use rules: to control when each runs: the staging deploy might run automatically on every push to main (when: on_success), while the production deploy requires manual approval (when: manual). Use GitLab's environment-scoped variables to store environment-specific secrets: go to Settings → CI/CD → Variables, set a variable's "Environment scope" to staging or production. A variable named DEPLOY_HOST scoped to production will shadow an unscoped variable of the same name only when the job's environment matches. This pattern lets you use the same job script for both environments with no conditionals — the correct values are injected by GitLab based on the environment: name: declaration in the job.

Should I use rules: or only:/except: in my GitLab CI pipeline?

Always use rules: for new pipelines. The only:/except: syntax is in maintenance mode and GitLab recommends migrating away from it. The rules: syntax is a superset: it supports the same branch and tag matching, plus arbitrary variable comparisons ($CI_PIPELINE_SOURCE == "merge_request_event"), file path filters (changes: [src/**/*.ts] to only run the job when TypeScript files changed), and the ability to set when: and override variables per rule. For MCP server pipelines, the most useful rules are: run tests on merge requests and branch pushes, run the image build only on main branch pushes, and run the production deploy only on main with when: manual. These cannot be expressed cleanly with only:/except: but are straightforward with rules:.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free