
Docker Development
- 227 installs
- 16 repo stars
- Updated August 2, 2026
- netresearch/docker-development-skill
Containerize services, run multi-service stacks locally, and debug Dockerfile issues so backend APIs and workers behave consistently from dev through deployment.
About
Provides Docker-centric development guidance for building, running, and troubleshooting containerized applications: compose stacks, Dockerfile patterns, networking, volumes, and iterative local debugging aligned with backend delivery.
- Dockerfile best practices
- docker compose multi-service dev
- Local env parity with production
- Container debugging workflows
- Image layering and cache efficiency
Docker Development by the numbers
- 227 all-time installs (skills.sh)
- +12 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #379 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/docker-development-skill --skill docker-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 227 |
|---|---|
| repo stars | ★ 16 |
| Last updated | August 2, 2026 |
| Repository | netresearch/docker-development-skill ↗ |
What it does
Containerize services, run multi-service stacks locally, and debug Dockerfile issues so backend APIs and workers behave consistently from dev through deployment.
Files
Docker Development
Patterns for building, testing, and deploying Docker containers.
Core Principles
1. Minimal -- Alpine/distroless, multi-stage 2. Secure -- Non-root USER, no layer secrets, pin versions 3. Testable -- CI-verifiable: entrypoint bypass, DNS mocking 4. Cache-efficient -- deps first, clean in same layer
Quick Reference
Multi-Stage Build (Node.js)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM node:20-alpine
RUN addgroup -g 1001 app && adduser -u 1001 -G app -D app
USER app
COPY --from=builder /app .
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "server.js"]Multi-Stage Build (Go -- scratch/distroless)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app/server /server
CMD ["/server"]Layer Optimization
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*Build Cache: Copy Dependency Files First
COPY package*.json ./
RUN npm ci
COPY . .Manifests before source keeps install layers cached on source-only changes.
BuildKit Secrets
RUN --mount=type=secret,id=ssh_key,dst=/root/.ssh/id_rsa git clone git@github.com:org/repo.gitENV/ARG/COPY secrets persist in docker history. Use --mount=type=secret.
Docker Bake (Multi-Platform)
target "app" {
platforms = ["linux/amd64", "linux/arm64"]
cache-from = ["type=gha"]
cache-to = ["type=gha,mode=max"]
}Security Anti-Patterns
| Anti-pattern | Fix |
|---|---|
FROM image:latest | Pin version: image:1.2.3-alpine |
No USER directive | adduser + USER appuser |
chmod 777 | Use specific permissions: chmod 550 |
privileged: true in compose | Remove or use specific cap_add |
volumes: [/:/host] | Mount only needed paths |
ports: ["0.0.0.0:3000:3000"] | Bind to 127.0.0.1:3000:3000 |
ENV DB_PASSWORD=secret | Use --mount=type=secret or compose secrets |
CI Testing Gotchas
1. Bypass entrypoint: docker run --rm --entrypoint php myimage -v 2. Mock upstream DNS: docker run --rm --add-host backend:127.0.0.1 nginx-image nginx -t 3. Compose validation: cp .env.example .env before docker compose config 4. Secret scanning: Exclude .env.example, README, docs from scanners 5. Root-owned artifacts: in-container installs leave root-owned bind-mount dirs (EACCES on host) -- references/bind-mount-ownership.md
.dockerignore
Exclude: .git, node_modules/vendor, .env*, *.pem, *.key
Compose Essentials
- startup ordering:
depends_on.condition: service_healthy+healthcheckstart_period networks.internal: trueisolates databases from external accessprofiles: [debug]: services start only with--profile debug
References
references/ci-testing.md-- CI testing patterns for Docker imagesreferences/dind-testing-patterns.md-- Docker-in-Docker (DinD) testing patternsreferences/bind-mount-ownership.md-- root-owned bind-mount artifacts
# Checkpoints for docker-development skill
# Evaluates Docker configuration best practices
version: 1
skill_id: docker-development
# Only run this skill for repos that actually use Docker. Without this gate
# the skill reports Dockerfile/compose errors against TYPO3 extensions,
# libraries, skill repos, and other non-containerised projects.
# Uses chained `||` (not `test -o`, which is obsolescent in POSIX) and matches
# the patterns the existing checkpoints look for.
preconditions:
- type: command
pattern: "test -f Dockerfile || test -f Containerfile || test -f docker-compose.yml || test -f docker-compose.yaml || test -f compose.yml || test -f compose.yaml || test -f docker-bake.hcl"
mechanical:
# === DOCKERFILE/COMPOSE EXISTENCE ===
- id: DC-01
type: file_exists
target: Dockerfile
scope: application
severity: error
desc: "Dockerfile must exist"
- id: DC-02
type: file_exists
target: docker-compose.yml
scope: application
severity: warning
desc: "docker-compose.yml should exist for local development"
- id: DC-03
type: file_exists
target: .dockerignore
scope: application
severity: warning
desc: ".dockerignore should exist to exclude unnecessary files"
# === MULTI-STAGE BUILD CHECKS ===
- id: DC-04
type: regex
target: Dockerfile
scope: application
pattern: "FROM .+ AS .+"
severity: warning
desc: "Dockerfile should use multi-stage builds"
- id: DC-05
type: regex
target: Dockerfile
scope: application
pattern: "COPY --from="
severity: warning
desc: "Dockerfile should copy artifacts from build stages"
# === SECURITY: NON-ROOT USER ===
- id: DC-06
type: regex
target: Dockerfile
scope: application
pattern: "(USER [^r]|useradd|adduser)"
severity: warning
desc: "Dockerfile should run as non-root user"
- id: DC-07
type: not_contains
target: Dockerfile
scope: application
pattern: "USER root"
severity: warning
desc: "Dockerfile should not explicitly run as root"
# === HEALTH CHECK ===
- id: DC-08
type: contains
target: Dockerfile
scope: application
pattern: "HEALTHCHECK"
severity: warning
desc: "Dockerfile should define a HEALTHCHECK instruction"
- id: DC-09
type: regex
target: docker-compose.yml
scope: application
pattern: "healthcheck:"
severity: warning
desc: "docker-compose.yml should define healthcheck for services"
# === SECURITY: NO SECRETS IN DOCKERFILE ===
- id: DC-10
type: not_contains
target: Dockerfile
scope: application
pattern: "ENV.*PASSWORD"
severity: error
desc: "Dockerfile must not contain hardcoded passwords in ENV"
- id: DC-11
type: not_contains
target: Dockerfile
scope: application
pattern: "ENV.*SECRET"
severity: error
desc: "Dockerfile must not contain hardcoded secrets in ENV"
- id: DC-12
type: not_contains
target: Dockerfile
scope: application
pattern: "ENV.*API_KEY"
severity: error
desc: "Dockerfile must not contain hardcoded API keys in ENV"
- id: DC-13
type: not_contains
target: Dockerfile
scope: application
pattern: "ARG.*PASSWORD"
severity: warning
desc: "Dockerfile should not pass passwords as build args"
# === BEST PRACTICES ===
- id: DC-14
type: contains
target: .dockerignore
scope: application
pattern: ".git"
severity: warning
desc: ".dockerignore should exclude .git directory"
- id: DC-15
type: contains
target: .dockerignore
scope: application
pattern: "node_modules"
severity: info
desc: ".dockerignore should exclude node_modules if applicable"
- id: DC-16
type: regex
target: Dockerfile
scope: application
pattern: "LABEL.*maintainer|MAINTAINER"
severity: info
desc: "Dockerfile should have maintainer information"
- id: DC-17
type: not_contains
target: Dockerfile
scope: application
pattern: ":latest"
severity: warning
desc: "Dockerfile should pin base image versions, not use :latest"
# === .DOCKERIGNORE SECURITY ===
- id: DC-18
type: contains
target: .dockerignore
scope: application
pattern: ".env"
severity: warning
desc: ".dockerignore should exclude .env files to prevent secret leakage"
- id: DC-19
type: command
scope: application
pattern: "test -f .dockerignore && grep -qE '\\*\\.pem|\\*\\.key' .dockerignore || true"
severity: info
desc: ".dockerignore should exclude private key files (*.pem, *.key)"
# === COMPOSE HEALTH CHECK ORDERING ===
- id: DC-24
type: command
scope: application
pattern: "test -f docker-compose.yml && grep -q 'condition:' docker-compose.yml || test -f compose.yml && grep -q 'condition:' compose.yml || true"
severity: info
desc: "Compose depends_on should use condition: service_healthy for startup ordering"
# === MODERN COMPOSE FILENAME ===
- id: DC-25
type: command
scope: application
pattern: "test -f docker-compose.yml || test -f compose.yml"
severity: warning
desc: "Docker Compose file should exist (docker-compose.yml or compose.yml)"
# === LAYER OPTIMIZATION: CLEANUP IN RUN ===
- id: DC-26
type: command
scope: application
pattern: "! grep -q 'apt-get install' Dockerfile 2>/dev/null || grep -q 'rm -rf /var/lib/apt/lists' Dockerfile"
severity: info
desc: "Dockerfile should clean apt cache in the same RUN layer as install"
# === INTERNAL NETWORKS ===
- id: DC-27
type: command
scope: application
pattern: "test -f docker-compose.yml && grep -q 'internal:' docker-compose.yml || test -f compose.yml && grep -q 'internal:' compose.yml || true"
severity: info
desc: "Compose should use internal networks for database isolation"
llm_reviews:
# === SUBJECTIVE CHECKS (require LLM judgment) ===
- id: DC-20
domain: docker-security
prompt: |
Review the Dockerfile for security best practices:
- Base image is from a trusted source (official images, verified publishers)
- Minimal base image is used (alpine, distroless, slim variants)
- No sensitive data or credentials are embedded
- Proper permission handling (chmod, chown)
- No unnecessary packages installed
Report any security concerns found.
severity: warning
desc: "Review Dockerfile security configuration"
- id: DC-21
domain: docker-security
prompt: |
Check docker-compose.yml for security issues:
- No secrets or passwords in plain text
- Proper use of environment files or secrets management
- Network isolation between services
- No privileged containers unless absolutely necessary
- No host volume mounts that could be exploited
Report any security concerns found.
severity: warning
desc: "Review docker-compose.yml security configuration"
- id: DC-22
domain: docker-efficiency
prompt: |
Evaluate the Dockerfile for build efficiency:
- Proper layer ordering (dependencies before application code)
- Use of .dockerignore to minimize context
- Combining RUN commands to reduce layers
- Proper use of build cache
- Cleanup of temporary files and package caches
Report optimization opportunities.
severity: info
desc: "Review Dockerfile build efficiency"
- id: DC-23
domain: docker-development
prompt: |
Evaluate docker-compose.yml for development ergonomics:
- Volume mounts for live code reloading
- Environment variables for configuration
- Proper service dependencies with depends_on
- Reasonable default resource limits
- Development-specific overrides available
Report areas for improvement.
severity: info
desc: "Review docker-compose.yml development setup"
Bind-Mount Ownership: Root-Owned Artifacts on the Host
The Problem
Containers that run as root and write into a bind-mounted project directory leave root-owned files on the host. Typical producers:
docker compose run --rm app npm install # node_modules/ now root-owned
docker compose run --rm app composer install
docker compose run --rm app npm run build # dist/, public/build/ root-ownedThe host user then hits failures that look unrelated:
npm error EACCES: permission denied, rename '.../node_modules/@babel/code-frame' -> ...
rm: cannot remove 'node_modules/...': Permission deniedHost-side npm install, build-tool cleanup steps (e.g. webpack/Encore cleanupOutputBeforeBuild), and even git clean -fdx fail on these files.
Diagnosis
find node_modules public/build -maxdepth 2 -user root | headAny hit means a containerized process wrote there as root.
Cleanup (no sudo required)
Use a throwaway container — root inside the container can delete what root created, and the mount scopes it to the project:
docker run --rm -v "$PWD:/work" -w /work alpine \
sh -c 'rm -rf node_modules public/build dist'Then reinstall/rebuild as the host user.
Prevention
| Approach | How |
|---|---|
| Run as the host user | docker compose run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp app npm ci — the arbitrary UID has no writable home in the container, and npm writes its cache to $HOME; point HOME (or npm_config_cache) at a writable path |
| Fix the UID in the image | adduser -u 1000 ... + USER app matching the typical host UID |
| Compose-wide | user: "${UID:-1000}:${GID:-1000}" on dev services — note UID/GID are not exported environment variables in most shells (bash's UID is shell-only); set them in the project .env file or export UID GID before composing |
| Keep artifacts out of the mount | named volume over node_modules/, or build inside the image (multi-stage) instead of into the mount |
Rootless Docker / userns-remap avoids the issue entirely but changes semantics for the whole daemon.
Related Gotcha: Named Volumes Mask Image Content
A named volume mounted over a path (e.g. public/) is populated from the image only on first use. After deploying a new image, the volume still holds the old content — refresh it explicitly (temp container + docker cp/rsync) or recreate the volume as part of the deploy.
CI Testing Patterns for Docker Images
Comprehensive patterns for testing Docker images in CI/CD pipelines.
The Challenge
Docker images often have:
- Entrypoint scripts that run before any command
- Service dependencies (databases, caches) that don't exist in CI
- Network configurations referencing other containers
- Required environment variables that fail validation
These cause CI failures that don't occur in local development.
Pattern 1: Entrypoint Bypass
Problem
# Dockerfile
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm"]# CI - FAILS
- run: docker run --rm myimage php -v
# Output: entrypoint.sh runs, starts services, php -v never executes properlySolution
# Override entrypoint for direct command execution
- run: docker run --rm --entrypoint php myimage -v
- run: docker run --rm --entrypoint php myimage -m # List modules
- run: docker run --rm --entrypoint node myimage --version
- run: docker run --rm --entrypoint /bin/sh myimage -c "cat /etc/os-release"When to Use
- Verifying installed software versions
- Checking available extensions/modules
- Testing configuration files
- Running diagnostic commands
Pattern 2: DNS Mocking for Upstream Services
Problem
# nginx.conf
upstream backend {
server app:9000;
}# CI - FAILS
- run: docker run --rm nginx-image nginx -t
# Error: host not found in upstream "app:9000"Solution
# Provide fake DNS resolution for upstream hosts
- run: |
docker run --rm \
--add-host app:127.0.0.1 \
--add-host database:127.0.0.1 \
--add-host cache:127.0.0.1 \
nginx-image nginx -tMultiple Upstreams
- name: Test nginx config
run: |
docker run --rm \
--add-host php-fpm:127.0.0.1 \
--add-host mailpit:127.0.0.1 \
--add-host redis:127.0.0.1 \
myapp-nginx nginx -tPattern 3: Docker Compose Validation
Problem
# compose.yml
services:
app:
environment:
- DB_PASSWORD=${DB_PASSWORD:?Required}# CI - FAILS
- run: docker compose config
# Error: required variable DB_PASSWORD is missingSolution
- name: Create test environment
run: |
cp .env.example .env
# Replace all CHANGE_ME placeholders
sed -i 's/CHANGE_ME_[A-Z_]*/test_password/g' .env
- name: Validate compose syntax
run: docker compose config > /dev/nullAlternative: Inline Variables
- name: Validate compose
env:
DB_PASSWORD: test
REDIS_PASSWORD: test
run: docker compose config > /dev/nullPattern 4: Health Check Verification
Test Health Check Command
- name: Build image
run: docker build -t myapp:test .
- name: Test health check
run: |
# Start container
docker run -d --name test-container myapp:test
# Wait for health
timeout 60 bash -c 'until docker inspect test-container --format="{{.State.Health.Status}}" | grep -q healthy; do sleep 2; done'
# Verify
docker inspect test-container --format="{{.State.Health.Status}}"
- name: Cleanup
if: always()
run: docker rm -f test-containerWorker/Sidecar Services That Reuse an App Image
A compose service that reuses the app image (e.g. a queue worker on the php-fpm+nginx web image) inherits the image's baked-in `HEALTHCHECK`. Three traps, in the order they typically bite in CI:
1. Inherited check probes a daemon the worker doesn't run (nginx, php-fpm) → the worker is permanently unhealthy and breaks docker compose up -d --wait — and anything else gating on health. 2. `healthcheck: { disable: true }` is not a fix when `--wait` is used — compose fails with container ... has no healthcheck configured (explicitly listed services without a check are un-waitable). Give the worker a real check instead. 3. *A naive `pgrep -f` check is always healthy* — the CMD-SHELL wrapper's own command line contains the search string, so pgrep matches the probe shell itself, even with a dead worker.
services:
worker:
image: myapp:latest # inherits the web image's HEALTHCHECK
command: php bin/console messenger:consume async
healthcheck:
# WRONG: matches the probe's own shell -- healthy forever
# test: ["CMD-SHELL", "pgrep -f 'messenger:consume' || exit 1"]
# RIGHT: [c]haracter-class guard prevents self-match
test: ["CMD-SHELL", "pgrep -f '[m]essenger:consume' || exit 1"]
interval: 30s
timeout: 5s
retries: 3Verify any health probe both ways: process up → healthy AND process killed → unhealthy. The naive pgrep pattern passes the positive test and hides the bug.
Prefer letting a worker exit on its own limits (exec the daemon as PID 1, restart policy with backoff) over in-container while true ... || true loops that mask fatal errors from orchestration.
Pattern 5: Secret Scanning with Exclusions
Problem
Documentation references placeholder passwords:
<!-- README.md -->
Set `DB_PASSWORD=CHANGE_ME` in your .env file# CI - FALSE POSITIVE
- run: git ls-files | xargs grep -l "CHANGE_ME" && exit 1
# Fails on README.md, QUICKSTART.md, etc.Solution
- name: Check for leaked secrets
run: |
# Files that legitimately reference placeholders
EXCLUDE_PATTERN=".env.example|README|QUICKSTART|docs/|Makefile|\.github/"
# Find files with secrets, excluding legitimate references
FOUND=$(git ls-files | xargs grep -l "CHANGE_ME" | grep -vE "$EXCLUDE_PATTERN" || true)
if [ -n "$FOUND" ]; then
echo "Found secrets in:"
echo "$FOUND"
exit 1
fi
echo "No leaked secrets detected"Pattern 6: Multi-Platform Build Testing
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build multi-platform
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
load: false # Can't load multi-platform
cache-from: type=gha
cache-to: type=gha,mode=maxPattern 7: Integration Testing with Service Containers
jobs:
test:
services:
database:
image: mariadb:11
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Build app image
run: docker build -t myapp:test .
- name: Run integration tests
run: |
docker run --rm \
--network ${{ job.container.network }} \
-e DB_HOST=database \
-e DB_PASSWORD=test \
myapp:test npm testPattern 8: GitLab CI — image entrypoint must be a shell (or be overridden)
Unlike a test-time --entrypoint bypass (Pattern 1), GitLab runs every job's `script:` via `sh -c`. If the image used as a job image: has a non-shell ENTRYPOINT ["mytool"], the runner effectively runs mytool sh -c '…' → `No such command 'sh'`, and the job fails before the script runs.
job:
image:
name: registry.example.com/mytool:1.0
entrypoint: [""] # let the runner's shell execute the script
script:
- mytool --helpA CLI image also meant for docker run mytool … can keep ENTRYPOINT ["mytool"], but document that GitLab consumers must set entrypoint: [""]. If the image is primarily a CI image, prefer no tool entrypoint (use CMD).
Pattern 9: Restricted runner egress — bundle external assets at build time
CI runners (especially internal/self-hosted) often have no outbound internet. An image that fetches something at runtime (page.add_script_tag(url="https://cdn…/axe.min.js"), curl https://… in the entrypoint, a remote pip/npm install) works locally but fails in CI.
Download the asset at build time and load it from the image. Use a multi-stage build so the fetch tooling (curl, ca-certificates) stays out of the final runtime image:
# Stage 1: fetch external assets
FROM alpine:3.20 AS asset-builder
RUN apk add --no-cache curl
RUN mkdir -p /opt/axe-core \
&& curl -sSfL https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js \
-o /opt/axe-core/axe.min.js
# Stage 2: final image carries only the asset
FROM python:3.12-slim
COPY --from=asset-builder /opt/axe-core/axe.min.js /opt/axe-core/axe.min.js
ENV AXE_PATH=/opt/axe-core/axe.min.js…and have the app prefer the local file (CDN as a dev-only fallback).
Pattern 10: Test the built image, not just the editable dev install
A non-editable install in the image (pip install ., npm install <tarball>) does not behave like the editable/dev checkout your tests ran against. Classic failure: data files resolved by walking from `__file__` (Path(__file__).resolve().parents[2]/"data"/…) don't exist under site-packages, so the tool can't find its catalog/config inside the container even though pytest was green.
- Ship data files as package data (Python wheel
force-include/package_data; npmfiles), not via filesystem-relative paths. - Smoke-test the built image, not just the source tree:
- run: docker build -t app:test .
- run: docker run --rm --entrypoint python app:test -c "import app; app.load_catalog()"
- run: docker run --rm app:test render fixture.json /tmp/out # real command, end-to-endComplete CI Workflow Example
name: Docker CI
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup test environment
run: |
cp .env.example .env
sed -i 's/CHANGE_ME_[A-Z_]*/ci_test_value/g' .env
- name: Validate compose
run: docker compose config > /dev/null
build-and-test:
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build images
run: docker compose build
- name: Test PHP version
run: docker run --rm --entrypoint php myapp:latest -v | grep "8.4"
- name: Test nginx config
run: docker run --rm --add-host app:127.0.0.1 myapp-nginx nginx -t
- name: Start stack
run: |
cp .env.example .env
sed -i 's/CHANGE_ME_[A-Z_]*/ci_test/g' .env
docker compose up -d
- name: Wait for healthy
run: |
timeout 120 bash -c 'until docker compose ps | grep -q healthy; do sleep 5; done'
- name: Test endpoint
run: curl -f http://localhost/ || exit 1
- name: Cleanup
if: always()
run: docker compose down -vLocal boot-test pitfalls
When smoke/boot-testing an image by hand (not in the CI matrix):
- Host-port collisions mislead. If the published port (
-p HOST:CONTAINER) is already taken by another container,docker run -dleaves the new container unstarted (it stays inCreatedstate; the CLI typically exits non-zero, often125) while yourcurl localhost:HOSTis answered by the other container — a false pass, or a baffling failure. Use a free/unique host port, or skip-pand probe from inside:docker exec <c> sh -c 'curl -sf localhost:<port>'. - Foreground apps that log to a file leave `docker logs` empty. E.g. Tomcat started with
-fgwrites tologs/catalina.out, not stdout — an emptydocker logsdoes not mean "nothing happened". Read the in-container log files (docker exec <c> sh -c 'tail -n 80 .../catalina.out'), and check the process and state (docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' <c>). - Minimal/distroless images have no shell.
docker exec … sh/tail/pgrepwon't exist onscratch/distroless runtimes — probe with host-sidecurlagainst a published port,docker inspectfor state, or a debug sidecar (docker run --rm --pid container:<c> busybox …). - Grep for real failure signals, not benign noise. After a bundled-dependency swap, scan logs for
NoSuchMethodError|AbstractMethodError|LinkageError|IncompatibleClassChangeError(binary incompatibility) — not bareClassNotFoundException, which OSGi/plugin frameworks emit normally.
Docker-in-Docker (DinD) Testing Patterns
Patterns for running Docker inside Docker in CI environments (Molecule, Testcontainers, nested builds).
The Overlay-on-Overlay Problem
GitHub Actions runners (and most CI platforms) use the overlay2 filesystem driver for Docker. When you run Docker inside Docker (e.g., Molecule testing Ansible roles, Testcontainers, nested builds), the inner Docker daemon also tries to use overlay2. The Linux kernel cannot stack overlay-on-overlay — this fails with:
mount source: "overlay", fstype: overlay, err: invalid argumentThis affects any CI job that starts Docker containers from within a Docker container.
Solution: VFS Storage Driver
Configure the inner Docker daemon to use the vfs storage driver instead of overlay2. VFS is slower (it copies full layers instead of using overlays) but works reliably inside containers.
Molecule + geerlingguy.docker Role
# molecule/default/prepare.yml
- name: Prepare
hosts: all
tasks:
- name: Install Docker with VFS driver
ansible.builtin.include_role:
name: geerlingguy.docker
vars:
docker_daemon_options:
storage-driver: vfsDirect daemon.json Configuration
{
"storage-driver": "vfs"
}Write this to /etc/docker/daemon.json inside the container before starting Docker.
GitHub Actions Service Container
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
dind:
image: docker:dind
env:
DOCKER_OPTS: "--storage-driver=vfs"
options: --privilegedGitLab CI
test:
image: docker:latest
services:
- name: docker:dind
variables:
DOCKER_OPTS: "--storage-driver=vfs"
variables:
DOCKER_HOST: tcp://docker:2376Systemd in Containers
When testing with containers that run systemd (e.g., Molecule testing on systemd-based OS images), additional configuration is required:
# molecule/default/molecule.yml
platforms:
- name: instance
image: geerlingguy/docker-debian12-ansible:latest
command: /lib/systemd/systemd
privileged: true
cgroupns_mode: host
tmpfs:
- /run
- /run/lock
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rwWhy These Settings
| Setting | Purpose |
|---|---|
command: /lib/systemd/systemd | Starts systemd as PID 1 |
privileged: true | Grants access to host devices and cgroups |
cgroupns_mode: host | Shares host cgroup namespace (required for cgroup v2) |
tmpfs: /run, /run/lock | Provides writable tmpfs for systemd runtime state |
volumes: /sys/fs/cgroup | Mounts cgroup filesystem read-write |
Privileged Mode
When It Is Needed
- Docker-in-Docker: The inner Docker daemon needs to create network namespaces, mount filesystems, and manage cgroups
- Systemd containers: systemd requires cgroup access and device control
- iptables/networking: Containers that modify firewall rules or create network bridges
Security Implications
--privilegeddisables all security confinements (AppArmor, seccomp, capabilities)- The container can access all host devices and modify the host kernel
- In CI, this is generally acceptable because the runner is ephemeral
- In production, never use
--privileged— use specific--cap-addflags instead
# Production alternative: grant only needed capabilities
docker run --cap-add SYS_ADMIN --cap-add NET_ADMIN --security-opt apparmor=unconfined myimageAlternative Approaches
Docker Socket Mounting
Mount the host's Docker socket instead of running a full inner daemon:
# The container uses the HOST's Docker daemon
docker run -v /var/run/docker.sock:/var/run/docker.sock myimagePros: No overlay-on-overlay issue, faster, less resource usage Cons: Containers share the host daemon — no isolation, cleanup is shared, security risk (container can control host Docker)
Podman Rootless
Podman runs without a daemon and supports rootless nested containers:
# GitHub Actions
- name: Test with Podman
run: |
podman run --rm --privileged \
-v ./:/workspace:Z \
quay.io/podman/stable \
podman build /workspaceBuildah for Image Builds
If you only need to build images (not run containers), Buildah avoids DinD entirely:
- name: Build with Buildah
run: |
buildah bud -t myimage:test .
buildah push myimage:test docker-daemon:myimage:testComplete CI Example: Molecule with DinD
name: Ansible Role CI
on: [push, pull_request]
jobs:
molecule:
runs-on: ubuntu-latest
strategy:
matrix:
distro:
- debian12
- ubuntu2404
- rockylinux9
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install molecule molecule-plugins[docker] ansible
- name: Run Molecule
run: molecule test
env:
MOLECULE_DISTRO: ${{ matrix.distro }}With the corresponding Molecule prepare step using VFS:
# molecule/default/prepare.yml
- name: Prepare
hosts: all
tasks:
- name: Install Docker with VFS driver
ansible.builtin.include_role:
name: geerlingguy.docker
vars:
docker_daemon_options:
storage-driver: vfsTroubleshooting
| Error | Cause | Fix |
|---|---|---|
mount: overlay: invalid argument | Overlay-on-overlay | Set storage-driver: vfs |
Cannot connect to Docker daemon | Docker not started in container | Ensure --privileged and daemon is running |
failed to create shim task | Missing cgroup access | Add cgroupns_mode: host and cgroup volume |
System has not been booted with systemd | systemd not PID 1 | Set command: /lib/systemd/systemd |
OCI runtime error: container_linux.go | Insufficient permissions | Add --privileged or specific --cap-add |