Updated Nov 5, 20269 min read

GitLab CI/CD in 2026: Pipelines, Runners and Deploying a Backend

GitLab CI/CD runs jobs defined in .gitlab-ci.yml on shared or project runners. How pipelines, stages and environments work, and how to deploy an Encore backend from a GitLab job using the platform API or encore build docker.

GitLab CI/CD is the pipeline system built into GitLab: you describe jobs in YAML, runners execute them, and the same product that hosts the repository shows the pipeline next to the merge request. For a backend, that usually means install dependencies, run tests, build an artifact and deploy to staging or production.

GitLab stays the CI product, and Encore is infrastructure from code whose platform builds, provisions and deploys the backend when a rollout starts. This guide covers GitLab pipelines and runners first, then two ways to ship an Encore app from a GitLab job.

GitLab CI/CD at a glance

PieceRole
.gitlab-ci.ymlPipeline definition: stages, jobs, rules, variables
JobA script (and optional image, services, artifacts) that a runner executes
StageOrdered group of jobs; jobs in a stage run in parallel
RunnerAgent that picks up jobs and runs them
EnvironmentNamed deploy target (staging, production) with optional URL and protection
CI/CD variablesSecrets and config injected into jobs, including protected and masked ones

The GitLab pipelines documentation and runners documentation cover the full model.

Pipelines and .gitlab-ci.yml

A pipeline is created on push, merge request, schedule or manual run. It is made of stages that run in order; if any required job in a stage fails, later stages are skipped. A minimal backend pipeline looks like this:

# .gitlab-ci.yml stages: - test - deploy variables: NODE_VERSION: "22" test: stage: test image: node:${NODE_VERSION} script: - npm ci - npm test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH deploy_staging: stage: deploy image: curlimages/curl:8.10.1 script: - echo "Replace with your deploy command" environment: name: staging rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: on_success

Jobs can depend on earlier jobs with needs to start before the rest of a stage finishes, pass artifacts forward, and use rules to run only on the default branch or on merge requests. Manual jobs (when: manual) gate production. Environments record what was deployed where and link each deployment to the pipeline that produced it.

Keep secrets out of the YAML. Store tokens as CI/CD variables, mark them masked and protected, and restrict protected variables to protected branches.

Shared vs specific runners

Runners are the workers. On GitLab.com, GitLab-hosted runners are instance runners: no agent to install, a clean VM per job, Linux, Windows and macOS options. They fit most open-source and SaaS workflows.

Self-managed runners run on your hardware or cloud VMs. You register them to the instance, a group or a single project. Project and group runners are the usual meaning of "specific" runners: dedicated capacity, access to a private network, GPUs or a hardened image. Tag jobs so only the right runner picks them up, and use protected runners for jobs that need production credentials.

Choose hosted runners for zero maintenance and isolation per job. Choose self-managed runners when jobs must reach internal services, use custom executors or keep build caches on warm machines.

How do you speed up GitLab pipelines with caching and needs?

Two settings do most of the work. A cache keyed on the lockfile keeps node_modules or the npm cache between pipelines, and needs lets a job start as soon as the jobs it depends on finish instead of waiting for the whole previous stage:

stages: - test - build test: stage: test image: node:22 cache: key: files: - package-lock.json paths: - .npm/ script: - npm ci --cache .npm --prefer-offline - npm test build: stage: build needs: ["test"] script: - npm run build artifacts: paths: - dist/

Caches are best effort and can be missing on a fresh runner, so jobs must still work without one. Artifacts are the reliable way to pass build output to later jobs, and needs downloads artifacts only from the jobs it lists. Merge request pipelines that run with rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" keep the default branch pipeline for deploys and the merge request pipeline for tests.

Deploy jobs for a backend

A deploy job is an ordinary job in a later stage. Patterns that show up often:

PatternWhat the job does
Script + cloud CLILogs in with a short-lived token and updates a service (ECS, Cloud Run, Kubernetes)
Container registryBuilds an image, pushes it, then triggers a rollout that pulls the new tag
Platform APICalls a deploy API for an application platform that owns build and infra
GitOpsUpdates a manifest repo; a controller in the cluster applies it

Whatever you pick, prefer OIDC or short-lived tokens over long-lived keys in variables, run production deploys from protected branches, and keep staging automatic while production waits on a manual job if the team wants a human gate.

How do you run tests against Postgres in GitLab CI?

A job can start sidecar containers with services. The service is reachable by its image name as the hostname, or by an alias, and the variables you set on the job are passed to the service container as well:

integration_test: stage: test image: node:22 services: - name: postgres:16 alias: db variables: POSTGRES_DB: app_test POSTGRES_USER: app POSTGRES_PASSWORD: app DATABASE_URL: "postgres://app:app@db:5432/app_test" script: - npm ci - npm run migrate - npm run test:integration

The service container starts fresh for every job, so tests should create their schema through migrations rather than rely on state from an earlier run. Use a test-only password like this one and keep real credentials in masked variables.

How do you authenticate GitLab jobs to AWS without long-lived keys?

GitLab issues an OpenID Connect token to a job through id_tokens. Register GitLab as an OIDC identity provider in IAM, create a role whose trust policy matches the token's aud and the sub claim for your project and branch, and exchange the token for temporary credentials in the job:

deploy_production: stage: deploy image: name: amazon/aws-cli:latest entrypoint: [""] id_tokens: GITLAB_OIDC_TOKEN: aud: https://gitlab.com script: - > export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn "${AWS_ROLE_ARN}" --role-session-name "gitlab-${CI_PIPELINE_ID}" --web-identity-token "${GITLAB_OIDC_TOKEN}" --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - aws sts get-caller-identity rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual

The sub claim looks like project_path:mygroup/myapp:ref_type:branch:ref:main, so a trust policy condition on it limits the role to one project and branch. The credentials expire on their own, and there is no access key to rotate or leak from a variable. The same pattern works for GCP with Workload Identity Federation.

Deploying an Encore backend from GitLab

Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. Native git push deploy integrates with GitHub. Teams that keep the repository on GitLab still run tests in GitLab CI, then start an Encore rollout from a job or build a self-hosted image with the CLI.

Option 1: Trigger a rollout with the platform API

The platform API accepts a POST to /api/apps/{APP_ID}/envs/{ENV_NAME}/rollouts with a JSON body that names a branch, a commit sha, or latest_deploy. Authenticate with a Bearer token from the OAuth token endpoint, using an OAuth client created for the app. Store client_id and client_secret as protected CI variables.

# .gitlab-ci.yml (deploy job fragment) deploy_encore_staging: stage: deploy image: curlimages/curl:8.10.1 variables: APP_ID: "your-app-id" ENV_NAME: "staging" script: - | TOKEN=$(curl -sS -X POST "https://api.encore.dev/api/oauth/token" \ -d "client_id=${ENCORE_CLIENT_ID}" \ -d "client_secret=${ENCORE_CLIENT_SECRET}" \ -d "grant_type=client_credentials" \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') curl -sS -X POST \ "https://api.encore.dev/api/apps/${APP_ID}/envs/${ENV_NAME}/rollouts" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"sha\":\"${CI_COMMIT_SHA}\"}" environment: name: staging rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The rollout runs Encore's build and test phase, provisions infrastructure changes when needed, and deploys, the same sequence as a git push deploy described in the deploying docs. Monitor progress in the Cloud Dashboard under Deployments. Use a separate job with when: manual for production, and a different ENV_NAME.

Option 2: Build a Docker image in GitLab for self-hosting

If you self-host instead of using platform-managed deploys, follow the CI/CD integration guide: install the Encore CLI in the job, authenticate with an auth key from App Settings > Auth Keys when the app is linked to the platform, run encore build docker, and push the image to your registry.

build_image: stage: deploy image: docker:27 services: [docker:27-dind] script: - apk add --no-cache bash curl && curl -L https://encore.dev/install.sh | bash - ~/.encore/bin/encore auth login --auth-key="${ENCORE_AUTH_KEY}" - ~/.encore/bin/encore build docker --config infra.config.json registry.example.com/myapp:${CI_COMMIT_SHA} - docker push registry.example.com/myapp:${CI_COMMIT_SHA}

The --config file describes the infrastructure the image connects to; keep secret values out of it.

Application code the pipeline deploys

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create billing --example=ts/empty$ cd billing && mkdir billing

The service declares its database and secret next to the endpoint, with a billing/encore.service.ts file that exports new Service("billing") and a migration that creates the invoices table:

// billing/invoice.ts import { api } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { secret } from "encore.dev/config"; const db = new SQLDatabase("billing", { migrations: "./migrations" }); // Set with: encore secret set --type prod StripeKey const stripeKey = secret("StripeKey"); export const createInvoice = api( { expose: true, method: "POST", path: "/invoices" }, async ({ customerId }: { customerId: string }): Promise<{ id: string }> => { void stripeKey(); const row = await db.queryRow<{ id: string }>` INSERT INTO invoices (customer_id) VALUES (${customerId}) RETURNING id::text AS id`; return { id: row!.id }; }, );

encore run starts Postgres locally for the same schema. Secret values are set with encore secret set --type prod StripeKey and injected at runtime, so they never sit in the repository or in GitLab variables. On a platform deploy to AWS, Encore provisions the database, an Application Load Balancer for public APIs and the secret in AWS Secrets Manager, and Encore provisions an IAM role for the service. GitLab still runs your tests, and the deploy job only needs to start the rollout or build the image.

Best fit: Teams on GitLab who want CI next to the merge request and Encore to own infrastructure and deploy into their AWS or GCP account, or to produce the Docker image for self-hosting.

Good to know

  • Preview environments and automatic deploys on push require the GitHub integration (or a push to the Encore managed git remote); from GitLab, use the rollout API or encore build docker.
  • Prefer protected, masked variables for OAuth secrets and auth keys, and limit them to protected branches.
  • Keep encore.app build hooks for compile steps that belong in Encore's build, and leave lint or policy checks in GitLab jobs.

Frequently asked questions

What is GitLab CI/CD?

GitLab CI/CD is the built-in continuous integration and delivery system in GitLab. You define jobs in a .gitlab-ci.yml file, runners execute those jobs, and pipelines group them into stages such as build, test and deploy.

What is the difference between shared and specific runners?

GitLab-hosted (instance) runners are managed by GitLab and available across projects on GitLab.com. Self-managed runners you install yourself can be instance-wide, limited to a group, or dedicated to one project when you need private networks, custom images or isolated credentials.

Where do I put the pipeline configuration?

In a .gitlab-ci.yml file at the root of the repository by default. Jobs list the commands to run; stages define the order. Variables and protected secrets supply credentials that must not live in the file.

How do I deploy an Encore backend from GitLab CI?

After tests pass, a deploy job can call the Encore platform API to start a rollout for a branch or commit, using an OAuth client stored as CI variables. For self-hosted deploys, install the Encore CLI in the job, authenticate with an auth key, run encore build docker and push the image to your registry.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders