GitLab CI Error Guide: '401 Unauthorized: access forbidden' Container Registry Push Failures
Fix GitLab CI container registry 401 Unauthorized and denied access forbidden errors: CI_JOB_TOKEN scopes, CI_REGISTRY login, deploy tokens, dind, and dependency proxy auth.
- #gitlab-cicd
- #troubleshooting
- #errors
- #registry
Overview
When a GitLab CI job tries to docker push (or docker pull) against the GitLab Container Registry and the daemon answers with 401 Unauthorized or denied: access forbidden, the registry has accepted your request but rejected your credentials’ scope. The TCP connection works, TLS works, the image name parses — what fails is the registry deciding that the token you logged in with is not allowed to write (or read) that particular repository path. This makes the error feel arbitrary: the same pipeline pushes one image fine and is denied on the next.
The message in the job log is blunt about the authorization failure, even if it is vague about why:
$ docker push registry.gitlab.com/acme/platform/api:latest
The push refers to repository [registry.gitlab.com/acme/platform/api]
denied: access forbidden
ERROR: Job failed: exit code 1
A login that never succeeded in the first place produces a different but equally literal variant — note that this one fails before the push even starts:
$ docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
Error response from daemon: Get "https://registry.gitlab.com/v2/": unauthorized: authentication required
The HTTP status is fixed (401); the cause varies. It is almost always a credential-scope or login problem — the CI_JOB_TOKEN pointing at a project it does not own, a missing docker login, a dead deploy token, or a Docker daemon that the login never reached — not a bug in the registry itself.
Symptoms
- A push that worked on the default branch fails with
denied: access forbiddenfrom a different project’s or another group’s registry path. - The job log shows
unauthorized: HTTP Basic: Access deniedimmediately afterdocker login, so nothing is ever pushed. docker login“succeeds” withLogin Succeededbut the nextdocker pushstill returns401, hinting the login hit the wrong daemon.- A previously green pipeline starts failing after a deploy token expired, a Personal Access Token was rotated, or someone disabled the project’s Container Registry.
- Kaniko jobs fail with
error checking push permissionseven though plaindocker loginworks elsewhere.
docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
echo "CI_REGISTRY=$CI_REGISTRY CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE"
Login Succeeded
CI_REGISTRY=registry.gitlab.com CI_REGISTRY_IMAGE=registry.gitlab.com/acme/platform/api
docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
The push refers to repository [registry.gitlab.com/acme/platform/api]
denied: requested access to the resource is denied
ERROR: Job failed: exit code 1
A push that is denied after a successful login means the credential is valid but not authorized for that repository path — the classic cross-project CI_JOB_TOKEN problem.
Common Root Causes
1. Pushing to a different project’s registry with CI_JOB_TOKEN
CI_JOB_TOKEN (the value behind CI_REGISTRY_PASSWORD) is scoped to the project the pipeline runs in. It can read and write that project’s registry, but it has no write access to a different project’s registry path. Pushing to another team’s repo with the job token is the single most common cause of denied: access forbidden.
# .gitlab-ci.yml — running in acme/platform/api but pushing to acme/shared/base
push-base:
image: docker:27
services:
- docker:27-dind
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker push registry.gitlab.com/acme/shared/base:latest
The push refers to repository [registry.gitlab.com/acme/shared/base]
denied: access forbidden
ERROR: Job failed: exit code 1
The login succeeds (the token is real) but the push is denied because the job token only authorizes its own project. Use a deploy token or a Personal/Project Access Token with the write_registry scope for the target project, stored as masked CI variables:
docker login -u "$BASE_DEPLOY_USER" -p "$BASE_DEPLOY_TOKEN" registry.gitlab.com
docker push registry.gitlab.com/acme/shared/base:latest
2. Missing or incorrect docker login before push
If the docker login line is skipped, mistyped, or references a variable that is empty, the daemon falls back to anonymous access and the registry rejects the write. A common slip is logging in with the wrong variable (e.g. $CI_REGISTRY_TOKEN, which does not exist) instead of $CI_REGISTRY_PASSWORD.
# Broken: $CI_REGISTRY_TOKEN is undefined, so -p is empty
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_TOKEN" "$CI_REGISTRY"
- docker push "$CI_REGISTRY_IMAGE:latest"
Error response from daemon: Get "https://registry.gitlab.com/v2/": unauthorized: authentication required
ERROR: Job failed: exit code 1
The canonical, copy-pasteable login uses the predefined pair CI_REGISTRY_USER / CI_REGISTRY_PASSWORD, which GitLab injects for every job:
docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
3. dind not started, so login hit the wrong daemon
With the docker:dind service, docker login and docker push must talk to the dind daemon, not a stale socket. If DOCKER_HOST / DOCKER_TLS_CERTDIR are misconfigured, the login may quietly write credentials to a daemon that never serves the push, and you get a 401 despite “Login Succeeded”.
# Missing service or TLS vars: login writes config the push daemon never sees
variables:
DOCKER_TLS_CERTDIR: "" # disabling TLS without DOCKER_HOST=tcp://docker:2375
# services: [] -- no docker:dind started
$ docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
Login Succeeded
$ docker push "$CI_REGISTRY_IMAGE:latest"
error parsing HTTP 401 response body: ... unauthorized: HTTP Basic: Access denied
Start dind properly and point the client at it. The modern, TLS-enabled pattern:
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: "tcp://docker:2376"
DOCKER_CERT_PATH: "/certs/client"
DOCKER_TLS_VERIFY: "1"
4. Deploy token expired/revoked or missing the registry scope
When you authenticate with a deploy token or PAT instead of the job token, that credential carries explicit scopes. If it has expired, been revoked, or was created without write_registry (for push) or read_registry (for pull), the registry denies the operation even though the username/password “exist”.
# Sanity-check the credential directly against the registry v2 API
curl -i -u "$DEPLOY_USER:$DEPLOY_TOKEN" "https://registry.gitlab.com/v2/"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="https://gitlab.com/jwt/auth",service="container_registry",scope="..."
{"errors":[{"code":"UNAUTHORIZED","message":"HTTP Basic: Access denied"}]}
$ docker push registry.gitlab.com/acme/platform/api:latest
denied: requested access to the resource is denied
Recreate the deploy token under Settings > Repository > Deploy tokens with the write_registry (and read_registry, if you also pull) scopes ticked, set a future expiry, and update the masked CI variables.
5. Container Registry disabled, or pushing to a protected/immutable tag
A 401/denied can also be a feature or policy block, not a credential block. If Container registry is toggled off under Settings > General > Visibility, no credential can write. Separately, protected or immutable tags refuse writes from roles below the configured minimum, and cleanup policies can race a push.
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID" | jq '.container_registry_enabled'
false
$ docker push registry.gitlab.com/acme/platform/api:latest
denied: access forbidden
Re-enable the registry feature, or — if the tag is protected/immutable — push from a role (Maintainer/Owner) allowed to write that tag, or pick a tag the protection rule does not cover. Tags marked immutable cannot be overwritten at all.
6. Dependency proxy / cross-group pull without CI_DEPENDENCY_PROXY auth
Pulling base images through the GitLab Dependency Proxy, or pulling from another group’s registry, needs its own credentials. The job token authenticates the proxy only when you log in with CI_DEPENDENCY_PROXY_USER / CI_DEPENDENCY_PROXY_PASSWORD; skip that and the proxy returns 401.
# Pulling a cached base image through the dependency proxy
build:
image: docker:27
services:
- docker:27-dind
script:
- docker pull "$CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/library/alpine:3.20"
Error response from daemon: Head "https://gitlab.com/v2/.../alpine/manifests/3.20":
denied: access forbidden
Log in to the proxy host first using its dedicated predefined variables:
docker login -u "$CI_DEPENDENCY_PROXY_USER" -p "$CI_DEPENDENCY_PROXY_PASSWORD" "$CI_DEPENDENCY_PROXY_SERVER"
docker pull "$CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/library/alpine:3.20"
Diagnostic Workflow
Step 1: Read the exact error and note where login vs. push failed
# Re-run with the registry steps visible
docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
If docker login itself fails (authentication required / HTTP Basic: Access denied), the credential or daemon is wrong (Steps 3-4). If login succeeds but docker push is denied, the credential is valid but out of scope for that path (Step 2).
Step 2: Confirm the target repository path belongs to this project
echo "image target: $CI_REGISTRY_IMAGE"
echo "pushing to: <the path in your docker push line>"
If the push path is not under $CI_REGISTRY_IMAGE, you are crossing into another project’s registry — CI_JOB_TOKEN cannot write there. Switch to a deploy token / PAT with write_registry for that project.
Step 3: Test the credential directly against the registry v2 API
curl -i -u "$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD" "$CI_REGISTRY/v2/"
curl -i -u "$DEPLOY_USER:$DEPLOY_TOKEN" "$CI_REGISTRY/v2/" # if using a deploy token
A 200 OK (or 401 with a Bearer challenge that then resolves) confirms the credential is accepted at the registry edge. A hard 401 ... HTTP Basic: Access denied means the token is expired, revoked, or lacks the registry scope.
Step 4: Verify dind and the Docker client are talking to the same daemon
docker info | grep -i 'server version'
env | grep -E 'DOCKER_HOST|DOCKER_TLS_CERTDIR|DOCKER_CERT_PATH|DOCKER_TLS_VERIFY'
If docker info errors or DOCKER_HOST is unset/empty while a docker:dind service is declared, the login wrote credentials the push daemon never sees. Fix the TLS/host variables so client and dind agree.
Step 5: Check the registry feature, scopes, and tag protection
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID" | jq '{container_registry_enabled}'
If the registry is disabled, re-enable it. Otherwise check Settings > CI/CD > Protected tags / immutable tag rules and confirm the role behind your credential is allowed to write the tag you are pushing.
Example Root Cause Analysis
A shared build pipeline in acme/platform/api builds an image and pushes it to a central base-image repo in acme/shared/base so other teams can reuse it. It worked when run manually by a Maintainer, but every CI run now fails.
The job log shows a clean login followed by a denied push:
$ docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
Login Succeeded
$ docker push registry.gitlab.com/acme/shared/base:latest
The push refers to repository [registry.gitlab.com/acme/shared/base]
denied: access forbidden
ERROR: Job failed: exit code 1
The successful login but denied push is the tell: the credential is real, but not authorized for that path. Compare the push target against the project’s own registry path:
echo "CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE"
CI_REGISTRY_IMAGE=registry.gitlab.com/acme/platform/api
The push target acme/shared/base lives in a different project, so the pipeline’s CI_JOB_TOKEN (behind CI_REGISTRY_PASSWORD) has no write access there. Confirm by hitting the registry with the job token versus a deploy token created in the target project with write_registry:
curl -i -u "gitlab-ci-token:$CI_JOB_TOKEN" "registry.gitlab.com/v2/acme/shared/base/blobs/uploads/"
HTTP/1.1 401 Unauthorized
{"errors":[{"code":"UNAUTHORIZED","message":"HTTP Basic: Access denied"}]}
Fix: create a deploy token in acme/shared/base with write_registry (and read_registry), store it as masked variables BASE_DEPLOY_USER / BASE_DEPLOY_TOKEN, and log in with that credential before the cross-project push:
docker login -u "$BASE_DEPLOY_USER" -p "$BASE_DEPLOY_TOKEN" registry.gitlab.com
docker push registry.gitlab.com/acme/shared/base:latest
latest: digest: sha256:3f9c... size: 1789
The push now authorizes against a credential scoped to the target project, and the pipeline goes green.
Prevention Best Practices
- Always push only to
$CI_REGISTRY_IMAGE(and its sub-paths) with the job token; for any cross-project or cross-group push, provision a deploy token or PAT withwrite_registryup front rather than discovering the limit in CI. - Keep the canonical
docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"line in abefore_scripttemplate so no job forgets it or invents a non-existent variable. - Pin the
docker:dindservice version and setDOCKER_TLS_CERTDIR,DOCKER_HOST, and the cert vars explicitly so the client and daemon never drift apart. - Set calendar reminders (or short expiries with rotation) for deploy tokens and PATs, and confirm the registry scopes are still ticked after every rotation.
- For Kaniko jobs, write
/kaniko/.docker/config.jsonfrom the predefined registry variables and double-check the--destinationstays inside the project’s registry path. - For fast triage of a
401/deniedstorm, the free incident assistant can read the job log and point at the likely credential-scope cause. More pipeline fixes live in the GitLab CI/CD guides.
Quick Command Reference
# Canonical login to this project's registry (predefined variables)
docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
# Confirm you are pushing to this project's own path
echo "CI_REGISTRY=$CI_REGISTRY CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE"
docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
# Cross-project / cross-group push needs a deploy token with write_registry
docker login -u "$BASE_DEPLOY_USER" -p "$BASE_DEPLOY_TOKEN" registry.gitlab.com
docker push registry.gitlab.com/acme/shared/base:latest
# Test a credential straight against the registry v2 API
curl -i -u "$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD" "$CI_REGISTRY/v2/"
curl -i -u "$DEPLOY_USER:$DEPLOY_TOKEN" "$CI_REGISTRY/v2/"
# Verify the Docker client reaches the dind daemon
docker info | grep -i 'server version'
env | grep -E 'DOCKER_HOST|DOCKER_TLS_CERTDIR|DOCKER_CERT_PATH|DOCKER_TLS_VERIFY'
# Is the registry feature enabled for this project?
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID" | jq '{container_registry_enabled}'
# Authenticate to the dependency proxy before pulling cached base images
docker login -u "$CI_DEPENDENCY_PROXY_USER" -p "$CI_DEPENDENCY_PROXY_PASSWORD" "$CI_DEPENDENCY_PROXY_SERVER"
# Kaniko: build an auth config from the predefined registry variables
echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" \
> /kaniko/.docker/config.json
Conclusion
A 401 Unauthorized: access forbidden from the GitLab Container Registry is the registry rejecting your credential’s scope, not your network or image name. The usual root causes:
- Pushing to another project’s registry with
CI_JOB_TOKEN, which only writes its own project — use a deploy token or PAT withwrite_registry. - A missing or incorrect
docker loginbefore the push, so the daemon falls back to anonymous access. - A
docker:dinddaemon that never started or aDOCKER_HOST/TLS misconfig, so the login hit the wrong daemon. - A deploy token or PAT that is expired, revoked, or lacks the
write_registry/read_registryscope. - Container Registry disabled for the project, or a push to a protected/immutable tag the role cannot write.
- Pulling through the dependency proxy or another group without
CI_DEPENDENCY_PROXY_USER/PASSWORDauth.
Read the log to see whether docker login or docker push failed first — login failures point at the credential or daemon, while a denied push after a clean login almost always means the token is valid but out of scope for that registry path.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.