Docker-in-Docker on GitLab: TLS, Pinned Tags, Hardening Checklist
Set up TLS-enabled Docker-in-Docker on GitLab with pinned CLI/dind tags, shared certs, registry auth, and a production hardening checklist.
Use TLS-enabled Docker-in-Docker with pinned image tags when you need an isolated Docker daemon inside your CI job. If privileged mode is off the table for your runners, switch to socket binding on a trusted single-tenant runner, or move to rootless DinD or a daemonless builder like Kaniko. This article gives you the config, the certificate handling, and a hardening checklist to make the call yourself.
TL;DR:
- Using TLS-enabled DinD with pinned image tags ensures secure, stable builds in trusted environments, but increases setup complexity and requires careful certificate management.
- Mounting the Docker socket offers faster startup and fewer setup steps but sacrifices container isolation, making it suitable only for dedicated, controlled runners.
- Proper pinning of Docker CLI and daemon images avoids version mismatches that cause API errors, with recommended tags like docker:27.0-cli and docker:27.0-dind.
- Rootless DinD reduces security risks associated with privileged mode but may require additional configuration and compatibility checks before deployment.
- For private registry authentication, set the DOCKER_AUTH_CONFIG variable with correct JSON credentials, since each DinD service starts fresh without cached logins.
Table of Contents
- What Is Docker-in-Docker in GitLab CI, and When Do You Need It?
- Setting Up TLS-Enabled DinD: Runner Config and a Working Pipeline
- Docker CLI vs Docker DinD: Why Version Pinning Actually Matters
- Docker Socket Binding: The Faster, Riskier Alternative
- Running DinD on the Kubernetes Executor
- Registry Authentication With a Fresh DinD Daemon
- A Troubleshooting Checklist for DinD Failures
- Hardening DinD: Rootless Options and Least-Privilege Controls
- My Take: Pick the Posture, Then Stop Second-Guessing It
- Sources
- FAQ
What Is Docker-in-Docker in GitLab CI, and When Do You Need It?
Docker-in-Docker (DinD) runs a second, nested Docker daemon inside your CI job’s container, separate from whatever daemon powers the runner itself. Every job gets a clean daemon with no leftover images, networks, or containers from a previous pipeline, which matters the moment you run two build jobs in parallel on the same runner and don’t want them fighting over layer caches or container names.
That isolation is the entire reason DinD exists. Without it, concurrent jobs sharing one host daemon can collide: one job’s docker build clobbers a tag another job just pulled, or a stopped container from job A blocks a port job B needs. GitLab supports DinD on both the Docker and Kubernetes executors, and it’s the right tool when you’re building and testing container images on runners you trust and control.
It’s not the only option, though:
- Docker socket binding (DooD) mounts the host’s socket directly into the job, skipping the nested daemon entirely.
- Kaniko or Buildah build OCI images without touching a Docker daemon at all, which sidesteps the privileged-mode question from the start.
- Rootless DinD trades some convenience for a daemon that never runs as root.
If your runners are shared across teams or untrusted pipelines, read the socket-binding and hardening sections below before you reach for standard DinD.
Setting Up TLS-Enabled DinD: Runner Config and a Working Pipeline
Standard DinD requires privileged: true on the job to start an inner Docker daemon, since it needs kernel capabilities not available in normal containers. This privileged mode increases risk, as it enables broader access to host resources. GitLab’s runner documentation confirms this requirement, making it a necessary trade-off for classic DinD on the Docker executor.
Here’s the setup that GitLab recommends, in order:
- Register the runner with privileged mode enabled:
gitlab-runner register --docker-privileged. - In
config.toml, add a volume for certificates so the client and daemon can share them:volumes = ["/certs/client", "/cache"]. - In
.gitlab-ci.yml, declare thedocker:dindservice and setDOCKER_TLS_CERTDIR: "/certs"so both sides generate and trust the same certs. - Point your job at the daemon over TLS with
DOCKER_HOST: tcp://docker:2376, matching the certdir path. - Pin exact image tags for both the CLI and the daemon service.
| Variable | Purpose | Example value |
|---|---|---|
DOCKER_HOST | Where the client connects | tcp://docker:2376 |
DOCKER_TLS_CERTDIR | Shared cert directory | /certs |
DOCKER_TLS_VERIFY | Enforces cert validation | 1 |
image (job) | Pinned CLI image | docker:27.0-cli |
services (dind) | Pinned daemon image | docker:27.0-dind |
GitLab’s Docker-in-Docker guide is the canonical source for these exact variable names, and its certificate volume pattern is worth mounting from config.toml rather than reinventing it per project.
Docker CLI vs Docker DinD: Why Version Pinning Actually Matters
The docker:cli image ships just the client binary, nothing else. It has no daemon, so it’s meant to run alongside a separate docker:dind service that does the actual building and container management. Mixing these up, or assuming one image does both jobs, is the single most common DinD misconfiguration on GitLab pipelines.
The other common failure is version drift. If your job image is docker:cli (unpinned, tracking latest) and your service is docker:24-dind, a client-server API mismatch can throw cryptic errors mid-build, often something like an “unsupported API version” message that has nothing to do with your Dockerfile.
docker:cli— client only, no daemon, needs a paired service.docker:dind— full daemon plus CLI, runs as theservicesentry.- Pin both:
docker:27.0-clipaired withdocker:27.0-dind. - Never run
docker:latestin either slot in a production pipeline.
GitLab’s own guidance calls out docker:27.0-cli as a concrete pinning example for exactly this reason.
Pro Tip: Pin the CLI and dind tags to the same major.minor version every time, and bump them together in one commit. A CLI that’s two minor versions ahead of its daemon is the fastest way to burn an afternoon debugging a build that isn’t actually broken.
Docker Socket Binding: The Faster, Riskier Alternative
Mounting the host’s Docker socket into your job container, known as Docker-outside-of-Docker (DooD), skips the nested daemon entirely. Your job talks straight to the runner’s own Docker daemon, which means no privileged flag and noticeably faster job startup, since there’s no inner daemon to boot.
The mount is straightforward: add /var/run/docker.sock:/var/run/docker.sock to the volumes list in config.toml, either globally or per runner. GitLab documents this pattern as a recognized alternative to DinD, with a clear warning attached.
That warning matters. A job with socket access can see and control every other container on that host daemon, including containers from other pipelines running concurrently. This means there is no isolation boundary in this setup.
- Choose DooD only on dedicated, single-tenant runners where you control every pipeline that uses them.
- Avoid it on shared runner pools or anywhere untrusted code might run a build job.
- Mitigate state leakage with per-runner isolation and a cleanup step (
docker system prune) at the end of every job. - Never use DooD as a default for public or fork-triggered pipelines.
Running DinD on the Kubernetes Executor
DinD on Kubernetes runners works differently from the Docker executor because the “service” becomes a sidecar container in the job’s pod, not a linked container on a shared network.
- Declare
docker:27.0-dindas a service in.gitlab-ci.yml, exactly as you would on the Docker executor. - Set
DOCKER_HOSTtotcp://docker:2376for TLS, ortcp://docker:2375if you’ve deliberately disabled TLS for a trusted internal cluster. - Mount an
emptyDirvolume for/certsin the pod spec so the sidecar and job container can share generated certificates. - Confirm the Kubernetes executor’s runner config grants the pod the right service account and, where required, a privileged security context, since Kubernetes enforces this separately from GitLab’s own
privilegedflag.
Watch for a pod stuck in a non-ready state because the healthcheck port doesn’t match the DOCKER_HOST port, or a build that hangs because the emptyDir for /certs wasn’t declared on both containers. GitLab’s Kubernetes-specific docs cover the pod-level flags in more depth than most teams expect to need on the first pass.
Registry Authentication With a Fresh DinD Daemon
Every DinD service starts with no memory of previous logins, no cached credentials, nothing. That’s the isolation working as intended, but it also means a docker pull from a private registry fails unless you authenticate explicitly inside the job.
The cleanest fix is the DOCKER_AUTH_CONFIG CI/CD variable, set at the project or group level, holding the same JSON structure as a local ~/.docker/config.json. GitLab’s daemon picks it up automatically at job start.
- Set
DOCKER_AUTH_CONFIGas a masked, protected CI/CD variable rather than hardcoding it in YAML. - For credential helpers, mount the helper binary and its config into the DinD service alongside your cert volume.
- Keep the same
DOCKER_TLS_CERTDIRpath across service and job so authentication and TLS trust resolve together. - Fall back to
docker loginas a job step only for quick debugging, never for a committed pipeline.
GitLab’s build guide walks through the auth config format if your registry needs a nonstandard credential store.
A Troubleshooting Checklist for DinD Failures
Most DinD failures trace back to four things, and checking them in order saves real time.
- TLS handshake errors: confirm
DOCKER_TLS_CERTDIRmatches on both the service and the job, and that the cert volume actually mounted. - Permission denied on the socket: usually means
privileged: trueis missing, or you’re mixing DooD mount syntax into a DinD setup by mistake. - “Cannot connect to Docker daemon”: check
DOCKER_HOSTis pointing at the service alias name (docker), notlocalhost. - Version mismatch errors: verify CLI and dind image tags match, per the pinning advice above.
- Slow image pulls: GitLab’s own build documentation notes that a shared Unix socket volume outperforms a networked TCP daemon connection, and pairing that with a registry mirror cuts pull time further on image-heavy pipelines.
Run through DOCKER_HOST, DOCKER_TLS_CERTDIR, the privileged flag, and tag pinning, in that order, before you start reading job logs line by line.
Hardening DinD: Rootless Options and Least-Privilege Controls
Privileged containers disable seccomp and AppArmor protections by default, which is exactly what makes a container breakout possible in the first place. That’s not a theoretical concern from a vendor page. It’s documented directly by Docker’s own security guidance and echoed by independent security research on privileged containers.
- Evaluate
docker:dind-rootlessfirst. It runs the daemon as a non-root user, though Docker’s rootless documentation lists real prerequisites, likenewuidmap/newgidmapsupport, and some storage drivers behave differently under it. - Apply seccomp and AppArmor profiles at the host level even when a job must run privileged, since a scoped profile limits what a breakout can actually do.
- Segregate DinD workloads onto dedicated runners rather than mixing them into your general-purpose fleet, so a compromised job has a smaller blast radius.
- Sign and pin every image you pull into a DinD build, and audit runner configurations on a recurring schedule rather than once at setup.
Pro Tip: If your organization runs both privileged DinD and shared general-purpose runners, put them on physically or logically separate hosts. A seccomp profile helps, but runner segregation is what actually contains a bad day. For deeper syscall-level controls, seccomp and AppArmor profiles are worth implementing on the host regardless of which DinD variant you choose.
My Take: Pick the Posture, Then Stop Second-Guessing It
Here’s the decision flow I’d actually follow: if your runners are dedicated and trusted, use TLS-enabled DinD with pinned tags and call it done. If they’re shared or multi-tenant, don’t fight it, just move to socket binding on isolated runners or switch to Kaniko and skip the privileged-mode question entirely.

The mistake I see most often isn’t choosing DinD. It’s choosing DinD and then never revisiting that choice as the runner fleet grows and “trusted” quietly stops being true. Accept the extra risk when you can name exactly who runs jobs on that runner. The moment you can’t, that’s your signal to switch, not a reason to add one more mitigation on top of the last one.
If you’re not sure which posture your current setup actually has, a Kubernetes Health Check or a focused runner audit will tell you faster than another round of Slack debates.
— James
Sources
FAQ
Does DinD Always Require Privileged Mode on GitLab?
Standard docker:dind needs privileged: true on the Docker and Kubernetes executors because the nested daemon requires kernel capabilities a normal container lacks, as GitLab’s runner docs confirm. Rootless DinD reduces this requirement in some setups, though Docker’s rootless docs list prerequisites that don’t apply universally.
Why Shouldn’t I Use docker:latest in My Pipeline?
An unpinned docker:latest tag can silently update between pipeline runs, causing CLI-to-daemon version mismatches that throw confusing API errors. Pin both images together, for example docker:27.0-cli with docker:27.0-dind, so a build that worked yesterday still works today.
Is Mounting the Docker Socket (DooD) Safe?
It’s safe only on dedicated, single-tenant runners where you control every pipeline running there, since socket access lets a job see and manage every other container on that daemon. On shared or multi-tenant runners, standard DinD or a daemonless builder is the safer choice.
How Do I Authenticate to a Private Registry With DinD?
Set the DOCKER_AUTH_CONFIG CI/CD variable as a masked, protected variable holding your registry credentials in JSON, since a fresh DinD daemon starts with no cached logins. GitLab’s build documentation covers the exact format and credential-helper alternatives.
What’s the Fastest Way to Speed Up Slow DinD Builds?
Use a shared Unix socket volume instead of a networked TCP connection where your executor supports it, and add a registry mirror to cut redundant pulls. Combining both with consistent image pinning is the most reliable performance win across repeated pipeline runs.
Recommended
- GitLab Container Scanning, SAST and DAST: Shift Security
- Hardening the Docker Daemon and Container Runtime: The Host
- Securing Your CI/CD Pipeline: Locking Down the Most Attacked
- Secrets Management in GitLab CI: Stop Storing Long-Lived Keys With OIDC
Get 500 Battle-Tested DevOps AI Prompts — Free
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.