How-To · 8 min read

How to Lock a Staging Environment From Your CI/CD Pipeline

Lockit Team · 2026-09-09

Your team has agreed to lock staging before using it. People do it. Then a pull request merges, the pipeline deploys main to staging, and someone's half-finished QA pass is gone. The pipeline was never told about the lock.

CI is a user of staging like anyone else. If humans take a lock before deploying, the pipeline should take the same lock, wait or fail if someone holds it, and give it back when it's done. This guide shows how to do that with working GitHub Actions and GitLab CI examples that call the Lockit REST API.

The pattern works with any lock service that has an HTTP API. The details that matter most are the same everywhere: how you detect a refused lock, and how you guarantee release.

The pattern

Every pipeline that touches a shared environment should follow four steps:

  1. Acquire the lock for the resource, with a duration a little longer than the job's timeout.
  2. Wait or fail if someone else holds it. Retry for a bounded time, then give up with a clear message.
  3. Do the work: deploy, migrate, run the suite.
  4. Release in a step that runs whether the work succeeded, failed or was cancelled, but only if this job actually acquired the lock.

That last condition is the one people miss. If the release step runs unconditionally, a job that never got the lock will release someone else's.

Before you start

You need:

  • A Lockit workspace on a paid plan (the API is part of Starter and above; see pricing).
  • An API key, created from the Lockit App Home tab in Slack ("Create API Key").
  • A resource for the environment, for example staging. Create it in chat with @Lockit new or via the API.
  • An email address for the pipeline to lock as. The API's userEmail must belong to a member of your Slack workspace.

That last point needs a decision. The lock shows up in @Lockit list under whoever's email you use, and that person is who gets a takeover request if someone clicks "Ask for takeover". Use the email of a real person or a monitored shared account, not an address nobody reads.

Store the key as a CI secret (LOCKIT_API_KEY) and the email as a CI variable (LOCKIT_CI_EMAIL).

How the lock API answers

Two calls do most of the work. Base URL: https://api.lockitbot.com/api/locky, with the key in the X-API-KEY header.

# Lock for 45 minutes (duration is in minutes)
curl -X POST \
  -H "X-API-KEY: $LOCKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"userEmail":"ci@yourcompany.com","duration":45}' \
  https://api.lockitbot.com/api/locky/resources/staging/lock

# Release
curl -X POST \
  -H "X-API-KEY: $LOCKIT_API_KEY" \
  https://api.lockitbot.com/api/locky/resources/staging/release

A successful lock answers with text like Resource staging has been successfully locked and acquired by you.

Here's the detail that matters for CI: a refused lock still returns HTTP 200, with a body starting Error:. So checking the status code isn't enough. Your script must read the body and treat a leading Error: as "not acquired".

If you leave duration out, Lockit uses the resource's default, then the workspace default (48 hours if nothing is set). In CI, always pass it explicitly.

Picking the lock duration

The duration is your safety net. Release steps run in almost every case, but not all of them: a runner can die, a job can hit its timeout, the network can fail at the wrong moment. When that happens, the lock should expire on its own soon after.

Set the duration to the job's timeout plus a small margin. If the job times out at 30 minutes, lock for 45. A leaked lock then blocks staging for at most 45 minutes, not 48 hours.

GitHub Actions

This workflow deploys main to staging. It takes the Lockit lock, retries for up to 10 minutes if someone else holds it, deploys, and releases in an always-run step.

name: Deploy to staging

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-staging
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      LOCKIT_API: https://api.lockitbot.com/api/locky
      RESOURCE: staging
    steps:
      - uses: actions/checkout@v4

      - name: Acquire staging lock
        id: lock
        env:
          LOCKIT_API_KEY: ${{ secrets.LOCKIT_API_KEY }}
          LOCKIT_USER: ${{ vars.LOCKIT_CI_EMAIL }}
        run: |
          for attempt in $(seq 1 20); do
            body=$(curl -sS --fail-with-body -X POST \
              -H "X-API-KEY: $LOCKIT_API_KEY" \
              -H "Content-Type: application/json" \
              -d "{\"userEmail\":\"$LOCKIT_USER\",\"duration\":45}" \
              "$LOCKIT_API/resources/$RESOURCE/lock")
            case "$body" in
              Error:*)
                echo "Attempt $attempt: $body"
                sleep 30
                ;;
              *)
                echo "$body"
                echo "acquired=true" >> "$GITHUB_OUTPUT"
                exit 0
                ;;
            esac
          done
          echo "::error::Could not lock $RESOURCE within 10 minutes. Check @Lockit list."
          exit 1

      - name: Deploy
        run: ./scripts/deploy.sh staging

      - name: Release staging lock
        if: always() && steps.lock.outputs.acquired == 'true'
        env:
          LOCKIT_API_KEY: ${{ secrets.LOCKIT_API_KEY }}
        run: |
          curl -sS -X POST \
            -H "X-API-KEY: $LOCKIT_API_KEY" \
            "$LOCKIT_API/resources/$RESOURCE/release"

A few notes on why it's written this way.

curl --fail-with-body makes a 401 or 5xx fail the step instead of being mistaken for success. A refused lock is still a 200, which is why the case checks for Error:.

always() plus the output check. GitHub's documentation (2026) says always() makes a step run "even when canceled". That's what you want for release. GitHub also warns against always() for steps that could suffer a critical failure, since the workflow may hang until it times out, and suggests !cancelled() instead. That advice is aimed at steps like fetching sources. Here, the step is a single curl, and running it on cancellation is the whole point, so always() is the right choice. The steps.lock.outputs.acquired == 'true' condition stops a job that never got the lock from releasing someone else's.

The concurrency group stops two runs of this workflow from overlapping. It doesn't know about people, which is why the Lockit lock is there too. With the default queue setting, at most one run waits and an older waiting run is cancelled when a newer one arrives, which suits "deploy latest main". We compare the two mechanisms in GitHub Actions concurrency groups vs environment locks.

Wait or fail? For merges to main, waiting is usually right: the person on staging finishes, releases, and the deploy goes out. For a manually dispatched deploy, failing fast with a clear message might be better. Change the loop count to suit.

GitLab CI

GitLab's after_script is the natural place to release. Its behaviour in 2026, from GitLab's docs, matters here:

  • It runs after script, including when script fails with a script failure.
  • Since GitLab 17.0 it also runs when the job is cancelled while script is running.
  • It does not run by default when the job times out.
  • It runs in a new shell, has its own timeout (5 minutes by default), and doesn't change the job's exit code.

Because after_script runs in a separate shell, it can't see shell variables from script. Use a file in the project directory to record that the lock was acquired.

deploy_staging:
  stage: deploy
  image: alpine:3.20
  resource_group: staging
  timeout: 30m
  variables:
    LOCKIT_API: https://api.lockitbot.com/api/locky
    RESOURCE: staging
  before_script:
    - apk add --no-cache curl
  script:
    - |
      acquired=""
      for attempt in $(seq 1 20); do
        body=$(curl -sS --fail-with-body -X POST \
          -H "X-API-KEY: $LOCKIT_API_KEY" \
          -H "Content-Type: application/json" \
          -d "{\"userEmail\":\"$LOCKIT_CI_EMAIL\",\"duration\":45}" \
          "$LOCKIT_API/resources/$RESOURCE/lock")
        case "$body" in
          Error:*) echo "Attempt $attempt: $body"; sleep 30 ;;
          *) echo "$body"; touch .lockit-acquired; acquired=1; break ;;
        esac
      done
      [ -n "$acquired" ] || { echo "Could not lock $RESOURCE within 10 minutes"; exit 1; }
    - ./scripts/deploy.sh staging
  after_script:
    - |
      if [ -f .lockit-acquired ]; then
        curl -sS -X POST -H "X-API-KEY: $LOCKIT_API_KEY" \
          "$LOCKIT_API/resources/$RESOURCE/release"
      fi
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Set LOCKIT_API_KEY as a masked CI/CD variable and LOCKIT_CI_EMAIL as a normal one.

resource_group: staging serialises this job against other GitLab jobs in the same group, which is GitLab's built-in equivalent of a concurrency group. The Lockit lock covers everything GitLab can't see: people, other projects, other CI systems.

Because after_script doesn't run on a timeout, the 45-minute lock duration is what protects you in that case. Keep it close to the job timeout.

Making failures readable

When the pipeline is blocked, the person reading the log should see who has the environment. Before the retry loop, fetch the resource and print the holder:

curl -sS -H "X-API-KEY: $LOCKIT_API_KEY" \
  "$LOCKIT_API/resources/$RESOURCE" \
  | jq -r '"Status: \(.status), held by \(.lockedBy // "nobody") until \(.lockedTillHuman // "-")"'

GET /resources/{name} returns fields including status, lockedBy and lockedTillHuman, so a blocked job can say "held by priya@... until 16:30 UTC" instead of "lock failed".

Common mistakes

MistakeWhat happensFix
Checking only the HTTP statusA refused lock (200, Error: body) is treated as successCheck the body for a leading Error:
Unconditional release stepA job that never got the lock releases someone else'sRecord acquisition and release only if set
No explicit durationFalls back to resource or workspace default, possibly 48 hoursPass duration = job timeout + margin
Release only on successFailed deploys leave staging lockedif: always() / after_script
Locking as an unread addressTakeover requests go nowhereUse a real member's or monitored email

Where this fits

This handles the pipeline side. People still lock and release from chat with @Lockit lock staging, see the pipeline's hold in @Lockit list, and use "Alert when released" if CI has it. For the overall policy, see how to manage shared staging environments. For QA teams juggling several environments, QA environment scheduling covers the human side.

The API is on Lockit's paid plans, with a 15-day trial. The full endpoint list is in the API docs.

FAQ

What happens if the runner dies before the release step?

The release never runs, so the lock stays until its duration expires. That's why you set the duration to roughly the job timeout plus a margin rather than relying on the default.

Should the pipeline wait for the lock or fail immediately?

Wait for automatic deploys of main, so they go out once the current holder is done. Fail fast for manual or ad-hoc deploys, so the person who triggered it can decide what to do.

Do I still need GitHub concurrency groups or GitLab resource groups?

Yes. They're cheap and they stop two runs of the same pipeline overlapping before either one calls the API. The external lock adds what they can't see: people and other systems.

Can the pipeline lock more than one resource?

Yes. Make one lock call per resource (for example staging and staging-db), record each one you acquired, and release each in the always-run step.

Stop asking who's on staging.

Add Lockit to Slack in a minute. Free for your first 2 resources.