Docker Testing
SkillCloud & infraBuild and smoke-test the Docker images with docker compose. Use when touching a Dockerfile, the bundle build, or compose config.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Docker Testing skill
What this skill tells your AI
The instructions your AI receives, as published by nrjdalal/zerostarter in .agents/skills/docker-test/SKILL.md and read by ahel’s review.
Full stack, the same Dockerfiles prod ships:
docker compose build --no-cache && docker compose up
Scope to one service while iterating:
docker compose build --no-cache api && docker compose up -d api
curl -sf --retry 30 --retry-delay 1 --retry-connrefused http://localhost:4000/api/health
docker compose logs -f api
docker compose down
.env
-
Where it comes from. The build reads
.envas a BuildKit secret (compose wiressecrets: dotenvfrom./.env; a plain build passes--secret id=dotenv,src=.env) and validates it in full;uploads it viaenv_file: .env. The secret mounts only during the build RUN and never lands in a layer. A missing.envfails fast: compose reports "secret file not found", docker build "secret not provided"; invalid runtime env crashes the container loudly at boot. -
Smoke build vs deploy image. A clean checkout has no real
.env, so give a smoke build a dummy from.env.examplewith the required blanks filled:sed -e 's|^BETTER_AUTH_SECRET=$|BETTER_AUTH_SECRET=dummy|' \ -e 's|^GITHUB_CLIENT_ID=$|GITHUB_CLIENT_ID=dummy|' \ -e 's|^GITHUB_CLIENT_SECRET=$|GITHUB_CLIENT_SECRET=dummy|' \ -e 's|^GOOGLE_CLIENT_ID=$|GOOGLE_CLIENT_ID=dummy|' \ -e 's|^GOOGLE_CLIENT_SECRET=$|GOOGLE_CLIENT_SECRET=dummy|' \ -e 's|^POSTGRES_URL=$|POSTGRES_URL=postgres://dummy:dummy@localhost:5432/dummy|' \ .env.example > .envEmpty optionals (e.g.
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=) pass viaemptyStringAsUndefined. A smoke build is disposable: Next inlinesNEXT_PUBLIC_*into the bundle at build, so a dummy-built web image carries dummy URLs forever. Deploy images build with the real.env. -
--no-cacheafter any.envedit. Secret contents are not part of BuildKit's cache key, so a plain rebuild reuses the cached build RUN and silently ships stale baked values (web'sNEXT_PUBLIC_*). -
Skip
SKIP_ENV_VALIDATION. Build and runtime both load a real.env, so both validate real values. The flag no longer bypasses validation anyway: it only substitutes shape-valid dummies for missing required vars while zod defaults and transforms still run (HONO_PORTdefaults to 4000,HONO_TRUSTED_ORIGINSparses to an array). Reserve it for CI, which genuinely lacks a.env. The web deploy uses the narrowerSKIP_ENV_VALIDATION_SERVER, which dummies only server secrets while still validating theNEXT_PUBLIC_*it inlines. -
NODE_ENVis read at runtime. The API bundle is built withbun build --env disable, so bun does not bakeprocess.env.NODE_ENVfrom the build shell, where.envis never sourced and the value would always be "development". Whatuploads from.envwins:/api/healthreports it asenvironment, andNODE_ENV=localwithAGENT_SIGNIN_ENABLED=truemounts the agent sign-in inside the container. -
Database. Without a reachable
POSTGRES_URL, DB-backed routes (e.g./api/v1/user) return 500 while/api/healthstays green. The end-to-end suite below needs one: a disposable container (bunx pglaunch -k), written into.envashost.docker.internal:<port>so the containers reach it, and migrated first withPOSTGRES_URL=<the localhost form> bun run db:migrate. -
Direct
docker run --env-filedoes not strip inline comments:HONO_RATE_LIMIT=60 # notearrives as"60 # note", coerces to NaN, and validation rejects it. Compose's parser strips them; fordocker run, sanitize first:sed 's/ #.*//' .env > .env.docker. -
Ports
4000and3000collide with a running dev stack; for side-by-side testing bump the compose mappings (e.g.14000:4000) in a scratch checkout.
Self-containment check (catches runtime auto-install)
The api runner ships only bundle/, so the bundle must resolve every import with no node_modules. When one is missing, Bun auto-installs it from npm at runtime, so a container that "works" online may be downloading packages on every cold start. Prove it offline:
sed 's/ #.*//' .env > .env.docker
docker run -d --name t-offline --network=none --env-file .env.docker <image>
docker exec t-offline sh -c 'for i in $(seq 1 30); do wget -qO- http://localhost:4000/api/health && exit 0; sleep 1; done; exit 1'
docker logs t-offline # on failure: "Cannot find package 'X'" = unresolved import
docker rm -f t-offline
Forensics on an online container: docker diff <name> | grep .bun/install/cache; entries there mean auto-install fired (history: --external hono in the bundle build fetched hono from npm at cold start).
Single-libc check (web image, catches silent re-bloat)
The web build prunes the unused libc stack from standalone (libc is auto-detected at build; an alpine base means musl). Store-layout or dep-rename drift regresses it back into bloat silently, so assert after any web image build:
docker run --rm --entrypoint sh zerostarter-web -c \
'find /app/node_modules \( -name "*linux-*-gnu*" -o -name "*sharp-linux-*" -o -name "*libvips-linux-*" \) ! -type l | grep . && echo "FAIL: glibc stack shipped" && exit 1; echo OK'
Expect OK. ! -type l skips dangling glibc-named symlinks (expected leftovers); only real files count. Any hit means the excludes in web/next/next.config.ts stopped matching, so fix the patterns, never delete the assertion.
End-to-end golden suite
With the stack up on a fresh disposable database, run every *.e2e.test.ts under tests/ against it:
E2E_POSTGRES_URL=postgres://postgres:postgres@localhost:<port>/postgres bun run test:e2e
E2E_API_URL and E2E_WEB_URL default to the compose ports. The suite signs in as LocalAgent and asserts the stage is local (so .env needs NODE_ENV=local and AGENT_SIGNIN_ENABLED=true; it is a suite for a local-stage stack, never a deployed one, and a URL whose host is not local is refused at load), drives the organization plugin, the console routes, the waitlist and the web pages, and snapshots every contract response after normalizing ids, timestamps and the build version. A snapshot mismatch is a contract change: review it, then bun run test:e2e --update-snapshots. The users list assumes LocalAgent is the only account, which is why the database is fresh; E2E_POSTGRES_URL is what lets the suite seed and remove a second account for the role and ban flows, and it refuses any host that is not local and any database holding an account beyond the agent and the seed, so neither the shared database nor a populated local one can be seeded by mistake; the stack's own POSTGRES_URL must be disposable for the same reason, since the suite writes through the API. Done when it prints 0 fail twice in a row: the second run proves the goldens are deterministic and that a run removes what it created (its organization, rules, signups, seeded account and sessions; the audit log keeps its rows by design, and the goldens read only the entries a run adds).
In a browser against the images the Login (agents) button is absent: its guard is Next's build-time NODE_ENV, so a production build compiles it out. Submit the same form it posts from the page's console, and the 302 lands on the dashboard with the session (cookies on localhost do not isolate by port):
document.body.appendChild(Object.assign(document.createElement("form"), { method: "POST", action: "http://localhost:4000/api/agents/sign-in-as" })).submit()
Notes
- Start the daemon if needed:
open -a Docker, then polldocker infountil ready. .dockerignoreexcludes real.env*from the build context (only.env.exampleenters), so the context itself carries no secret.
Signals
- GitHub stars
- 63
- Forks
- 11
- Last commit
- Sep 2026
ahel review
S4info
community integration — published by nrjdalal, not docker
Automated review, not a security audit. Ruleset v1.
Advanced
- Catalog kind
- skill
- Gateway key
docker-test- Source
- github.com/nrjdalal/zerostarter