
In short
- A pipeline that receives user files and holds an API key is a target: treat it like one.
- Minimal image, base pinned by digest, and a non-root process.
- Docker Scout in CI must be able to stop the deploy, not just produce a report.
- Secrets are mounted at run time; they never travel in an image layer.
- The development environment uses the same restrictions as production.
Docker pipeline security usually gets reviewed at the end, when the image is already in production. By then, the decisions that weigh the most have been made: which base was used, which user runs the process, and where the API key ended up.
I come to this topic from a concrete case: I processed 80,000 folios with OCR + LLM in production. A pipeline like that combines exactly what makes a container sensitive: files uploaded by strangers, native binaries to read images, and a paid credential.
One caveat: that pipeline ran on cron, directly on an AlmaLinux VPS. This article is how I package and secure a workload with that same profile in Docker. The code is illustrative, not the client's.
The risk profile of an OCR + LLM pipeline
Before scanning anything, it helps to write down what can go wrong. A receipt verifier has four attack surfaces:
- The input is untrusted. Every receipt is an image someone uploaded from the internet. The code that decodes it processes hostile data by design.
- There are native dependencies. Tesseract and the image libraries are written in C. Their flaws are memory flaws, and those are the ones that end in code execution.
- There is a credential that costs money. The LLM API key is worth money. If it leaks, someone else spends your budget.
- The CI pipeline has permissions. Whoever controls the build controls what reaches production.
The strategy is not to avoid every vulnerability. It is to assume one will exist and limit what an attacker can do with it.
Security analysis in Docker pipelines: start with the image
Almost everything a scanner reports is decided in the Dockerfile. An image with fewer packages has fewer vulnerabilities to patch and fewer tools available to an intruder.
These are the four decisions that reduce risk the most, in a single file:
# A tag can change its contents; a digest cannot. Pin the base.
FROM python:3.12-slim@sha256:<digest> AS build
WORKDIR /app
COPY requirements.txt .
# With hashes, pip rejects a tampered package even if the name matches.
RUN pip install --no-cache-dir --require-hashes --prefix=/install -r requirements.txt
FROM python:3.12-slim@sha256:<digest>
# Only what OCR needs. No compilers, no recommended packages.
RUN apt-get update \
&& apt-get install -y --no-install-recommends tesseract-ocr \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /install /usr/local
COPY --chown=10001:10001 src/ /app/
# If someone exploits the image decoder, they do not get root.
USER 10001
ENTRYPOINT ["python", "/app/verifier.py"]
Each one deserves an explanation.
Multi-stage build. The tools for installing dependencies stay in the first stage. The final image carries only what is needed to run.
Base pinned by digest. A tag like 3.12-slim points to different contents every week. With the digest, today's build and tomorrow's use exactly the same base, and updates come in through a reviewed pull request.
Hashed dependencies. The package supply chain is a real vector. Verifying the hash of every package closes the door on a substituted version.
Unprivileged user. It is one line, and it is the one that changes the outcome of an incident the most.
Secrets deserve their own rule. Everything that enters a layer stays in the image, even if you delete it later. If the build needs a credential, use a BuildKit secret with --mount=type=secret: it is available during that step and is not stored in any layer.
Integrating Docker Scout for better protection
Docker Scout analyzes the image, builds the inventory of its packages and matches it against known vulnerabilities. Having the report is not the point. The point is deciding what happens when the report contains something serious.
A scan that only informs ends up ignored. The useful one is the one that stops the pipeline:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read # the job token does not need to write to the repo
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- uses: docker/build-push-action@v6
with:
context: .
load: true # analyzed before publishing, not after
tags: verifier:${{ github.sha }}
- uses: docker/scout-action@v1
with:
command: cves
image: local://verifier:${{ github.sha }}
only-severities: critical,high
exit-code: true # with serious findings, the job fails
The order is the key: build, analyze, and only then publish. A vulnerable image never reaches the registry.
Three adjustments that make the difference in practice:
- Filter by severity. If the pipeline fails on every minor finding, the team learns to bypass it. Block critical and high; log the rest.
- Re-analyze what is already deployed. New vulnerabilities are published after your build. Schedule a periodic analysis of the images running in production.
- Pin the actions too. Third-party actions run with your secrets. Pin them by commit SHA and give the token only the permissions the job needs.
It also pays to agree in advance on what happens with a finding. Without that agreement, every alert turns into a debate. The order that works is this:
- Update the base. Most findings live in system packages and are resolved by bumping the digest of the base image.
- Remove the package. If the vulnerable library is not used, the best fix is for it not to be in the image at all.
- Document the exception with an expiry date. If there is no patch and the risk is acceptable, record who accepted it, why, and until when. An exception without a date is an ignored finding.
And keep the package inventory of every published image. When the next headline vulnerability lands, the question "are we affected?" is answered with a search, not with an afternoon of archaeology.
A maintenance note: the inputs of these actions change between versions. Confirm the names against the documentation for the version you pin.
The running container: least privilege
The image defines what is inside. The way you run it defines what it can do. This is where you contain the damage from a vulnerability the scanner does not know about yet.
# ⚠️ Production: back up the database and test in staging first.
# - Read-only root: an intruder cannot leave files in the container.
# - /tmp without exec: whatever gets downloaded there cannot be executed.
# - No capabilities, no escalation: the process cannot gain privileges.
# - Memory and process limits: a malicious image cannot take the server down.
# - The key comes in as a mounted file, not a variable visible in inspect.
docker run --rm \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m \
--cap-drop ALL --security-opt no-new-privileges \
--memory 1g --pids-limit 200 \
--mount type=bind,src=/etc/verifier/llm_key,dst=/run/secrets/llm_key,readonly \
verifier:1.4.2
Each option answers one of the attack surfaces from the beginning. The hostile input is locked inside an unprivileged process. The key does not show up in the history or in environment variables. And a file designed to exhaust memory hits a limit.
One piece is missing: the network. A verifier only needs to talk to the database and the LLM API. Everything else should be closed, so an intruder cannot ship data wherever they want.
Optimal security configuration in development environments
The development environment is where bad habits are born: containers running as root "so it works", real keys in a .env file, and volumes that mount the whole disk.
The rule is that development uses the same restrictions as production. If the container will not start read-only on your laptop, that is the best place to find out.
services:
verifier:
build: .
read_only: true # same as production: no surprises on deploy
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
user: "10001:10001"
secrets: [llm_key]
volumes:
- ./samples:/data:ro # test receipts, never real data
secrets:
llm_key:
file: ./.secrets/llm_key # outside Git; a development key, not production's
Three practices complete the environment:
- Separate keys per environment. The development key has its own spending limit. If it leaks, the damage is bounded and it gets revoked without touching production.
- Test data, not copies of production. Real receipts contain personal data. Development uses fabricated samples.
- Secret detection before the commit. A secret scanner in the pre-commit hook and in CI catches the key before it reaches the history.
What NOT to do
- Use the
latesttag in production. You do not know what you are running, and you cannot roll back to the previous version with certainty. Use explicit versions and a digest. - Put secrets in
ENVorARG. They stay in the image history and anyone with access to the registry can read them. Deleting them in a later layer does not remove them. - Scan without consequences. A vulnerability report that cannot stop the deploy is documentation, not a control.
- Mount the Docker socket inside the container. That hands over control of the entire server. If a CI step needs it, isolate it in a disposable runner.
The next step
Docker pipeline security does not depend on one tool. It depends on a chain of small decisions: a minimal image, an analysis that actually blocks, secrets that never touch a layer, and a container that runs with just enough.
If your team needs to audit its pipelines, containerize a workload that currently runs directly on the server, or harden its infrastructure ahead of a security review, at Conqr Digital Agency we design architectures that scale. Let's review your case.
If you want to see the system this article grew out of, it is in the Panini Fan Zone case study. For the deployment side, take a look at the Cloud and DevOps service.
FAQ
- What is Docker Scout and what is it for in a pipeline?
- It is Docker's tool for analyzing images: it builds the inventory of packages in the image and matches it against known vulnerabilities. In a pipeline it works as a gate: if a critical or high vulnerability shows up, the image is not published.
- Why shouldn't I run containers as root?
- Because if an attacker manages to execute code inside the container, they inherit the privileges of the process. Without root, without capabilities and with a read-only filesystem, a flaw in a library has far less reach.
- Where do I keep an LLM API key in Docker?
- Outside the image. At build time, use BuildKit secrets. At run time, mount the key as a read-only file or use your platform's secrets manager. Never put it in an ENV instruction or in the repository.



