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:
- Acquire the lock for the resource, with a duration a little longer than the job's timeout.
- Wait or fail if someone else holds it. Retry for a bounded time, then give up with a clear message.
- Do the work: deploy, migrate, run the suite.
- 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 newor via the API. - An email address for the pipeline to lock as. The API's
userEmailmust 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 whenscriptfails with a script failure. - Since GitLab 17.0 it also runs when the job is cancelled while
scriptis 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
| Mistake | What happens | Fix |
|---|---|---|
| Checking only the HTTP status | A refused lock (200, Error: body) is treated as success | Check the body for a leading Error: |
| Unconditional release step | A job that never got the lock releases someone else's | Record acquisition and release only if set |
| No explicit duration | Falls back to resource or workspace default, possibly 48 hours | Pass duration = job timeout + margin |
| Release only on success | Failed deploys leave staging locked | if: always() / after_script |
| Locking as an unread address | Takeover requests go nowhere | Use 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.