Most teams that never touch Kubernetes still ship credentials into containers the same broken way: an ENV line or a --build-arg flag that looks like it disappears once the build finishes. It doesn't. Docker 18.09, released in November 2018, shipped BuildKit's --secret flag specifically to fix this, mounting a secret into a tmpfs at /run/secrets/<id> for the duration of a single RUN instruction and never writing it to a layer or the build cache. That's a build-time fix. Docker Swarm, whose own secrets feature (docker secret create, added in Docker 1.13) predates BuildKit secrets by nearly two years, solves a different problem — distributing credentials to running services — by transmitting them to the manager over mutual TLS and storing them in an encrypted Raft consensus log at /var/lib/docker/swarm/raft/. Neither mechanism substitutes for the other, and conflating them is how teams end up with a hardened build pipeline that still ships plaintext database passwords to every replica, or a locked-down runtime that pulls its base image from a Dockerfile with a private npm token baked into layer 4. This piece compares all three approaches — BuildKit secrets, Swarm secrets, and the env-var pattern everyone starts with — and where each one still leaves a gap.
Why is passing credentials via ENV or --build-arg dangerous?
ENV and --build-arg values are baked into image layer metadata and stay there permanently, regardless of what a later instruction does. Docker's own layer model means docker history <image> and docker inspect <image> can surface a build argument or environment variable set three layers ago, even if a subsequent RUN unset MY_SECRET or a later ENV MY_SECRET="" overwrites it in the running container — the original value is still committed to an earlier, immutable layer. This is the single most common root cause of credential exposure in container pipelines: a developer passes --build-arg NPM_TOKEN=xxx to authenticate a private registry pull, the build succeeds, and the token sits recoverable in the image indefinitely, including in any registry the image gets pushed to. Multi-stage builds reduce the blast radius if the secret is confined to a discarded intermediate stage, but the intermediate stage's layers still exist in the local build cache and in registry layer caches unless explicitly pruned. The fix isn't a smarter ENV pattern — it's not putting the secret in a layer-committing instruction at all.
How does BuildKit's --secret flag actually keep credentials out of image layers?
BuildKit's secret mount type solves this by never letting the secret enter the filesystem snapshot BuildKit commits as a layer. With docker build --secret id=npmtoken,src=./token.txt on the client side and RUN --mount=type=secret,id=npmtoken cat /run/secrets/npmtoken in the Dockerfile, the secret is mounted read-only into the build container's tmpfs for that one RUN instruction only, then discarded — per Docker's own build-secrets documentation. Because it's a mount rather than a COPY or ENV, it never appears in docker history, never gets committed to a layer digest, and never persists in the build cache the way a copied file or set variable would. This requires BuildKit, which has been the default builder for docker build since Docker Engine 23.0 (released February 2023); on older Engine versions it requires DOCKER_BUILDKIT=1 or the docker buildx plugin explicitly. The scope is intentionally narrow: it solves build-time secrets like private package-registry tokens or TLS keys needed to fetch a dependency, not credentials your running application needs after the container starts.
How do Docker Swarm secrets differ, and where's the real gap?
Swarm secrets solve runtime distribution: docker secret create sends the secret to the swarm manager over mutual TLS, the manager stores it in its Raft consensus log, and at deploy time it's decrypted and mounted into the target container's in-memory tmpfs at /run/secrets/<name>, read-only and scoped only to services explicitly granted access — never written to the container's writable layer or exposed via docker inspect. Docker's documentation confirms Raft logs are encrypted at rest by default, a protection added specifically to support the secrets feature. The gap is the key protecting those logs: the TLS and Raft-decryption keys are loaded into each manager's memory on start and, by default, persisted without additional protection. Docker's --autolock feature — off by default — requires a manager to be manually unlocked with a generated key on every restart, closing that gap; skipping it is a documented, real trade-off many Swarm deployments never revisit. A second, frequently missed point: plain Docker Compose without Swarm mode initialized falls back to bind-mounting the secrets file from the host filesystem — not the encrypted Raft-backed path — so secrets: in a non-swarm compose.yaml is convenience, not encryption at rest.
Which approach should a team actually use, and does it need Kubernetes?
Neither BuildKit secrets nor Swarm secrets require Kubernetes — both ship in the standard Docker Engine and Compose/Buildx tooling, which is exactly why teams on plain VMs or single-host deployments reach for them instead of standing up a Vault or a full orchestrator. The practical split is scope, not maturity: use BuildKit --mount=type=secret for anything the build needs once (registry auth, a compile-time signing key) and Swarm secrets, or an external secret manager mounted the same way, for anything the running application needs continuously (database passwords, API keys, TLS certs). Teams that use only one of the two typically still have a hole — build-only coverage still ships plaintext runtime credentials in an env var read by the app at startup, and runtime-only coverage still lets a forgotten --build-arg in an old Dockerfile leak into every image built from it going forward.
How does layer-level scanning catch what these mechanisms miss?
Even correct use of BuildKit and Swarm secrets doesn't guarantee a team's Dockerfiles are actually written that way — a single unreviewed ENV DB_PASSWORD= slipped into a feature branch's Dockerfile bypasses both mechanisms entirely, because nothing about docker build stops you from combining secure and insecure patterns in the same image. This is why credential exposure keeps showing up in container images years after BuildKit secrets became standard: the mechanism existing doesn't mean every Dockerfile author used it. Safeguard scans every layer of a container image — not just the final filesystem — using layer extraction, entropy analysis, and issuer-specific pattern matching, so an ENV or ARG value baked into an intermediate layer is caught even if a later layer overwrites or deletes it. Findings for issuer-known patterns are then verified live against the issuing service itself, for example an AWS key against sts:GetCallerIdentity or a GitHub token against /user, so a security team can distinguish a real, exploitable leak from an expired or placeholder credential without manually testing each one.