Skip to main content

Step 3 — The .deploy folder

CI builds your image from one fixed path: .deploy/docker/build/app.Dockerfile. This page tells you what goes in that folder for each chart, which project to copy it from, and how to test it locally before CI ever runs.

You do not have to understand Dockerfiles to finish this step. Copy the reference project's folder, let the prompt generator's project prompt adapt it, then run the local test.

Layout​

.deploy/docker/build/app.Dockerfile ← always, this exact path
.deploy/docker/build/app.Dockerfile.dockerignore ← always (named after the Dockerfile, see below)
.deploy/docker/build/Caddyfile ← php-generic + symfony
.deploy/docker/build/php-override.ini ← php-generic
.deploy/docker/runtime/php.ini ← symfony only
.deploy/docker/runtime/opcache.ini ← symfony only
.container-init.sh ← repo root, optional (per-pod setup, never migrations)

One image per project. Cron jobs and queue workers run the same image with a different command (step 5), so never add a second Dockerfile.

Rules every Dockerfile follows​

RuleWhy
First line # syntax=docker/dockerfile:1.7Enables cache mounts used below.
ARG NODE_VERSION, ARG PNPM_VERSION, ARG PHP_VERSION and use them in FROMCI passes these (24, 11, 8.5). Never hardcode a version.
The OCI_* ARG/LABEL block in the final stageCI passes build metadata; keep it verbatim.
EXPOSE 3000 (sveltekit, node-generic) or EXPOSE 80 (php-generic, symfony)Must match the chart's container port.
ENV NODE_ENV=production / APP_ENV=prodProduction build.
No COPY .env, no secrets as build argsConfig comes from Infisical at runtime.
Lockfile-driven installs (pnpm install --frozen-lockfile, composer install from composer.lock)Reproducible builds.
CMD [...] in exec form, no ENTRYPOINTThe chart overrides command: for cron/workers.
Node images: COPY .container-init.sh into the final stage if you have oneThe init container runs it from the image.

Copy from​

Chart / stackCopy .deploy/ fromAlso copy
sveltekitziggo-espn-quiz-fe–
node-generic, NestJS + Prismann-backstory–
node-generic, Node without a build stephardloopsupporter-api–
php-generic, Craft CMSonyourmarks-websitepublic/ready.php, .container-init.sh
php-generic, api-simpleziggo-espn-quiz-be (no frontend build)–
symfonywedstrijd-van-je-leven–

Clone the reference next to your project if you don't have it:

git clone git@gitlab.com:onyourmarks/k8s/<reference>.git ../<reference>
cp -R ../<reference>/.deploy .deploy

sveltekit and node-generic​

The SvelteKit Dockerfile in full (this is ziggo-espn-quiz-fe; the NestJS one differs only in the lines marked below):

# syntax=docker/dockerfile:1.7

ARG NODE_VERSION
FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /app
ENV NODE_ENV=production

ARG PNPM_VERSION
RUN corepack enable && corepack prepare pnpm@latest-${PNPM_VERSION} --activate

FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./

# Keep pnpm store cached across builds
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN pnpm run build

FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# OCI labels
ARG OCI_CREATED
ARG OCI_TITLE
ARG OCI_DESCRIPTION
ARG OCI_SOURCE
ARG OCI_REVISION
ARG OCI_VERSION
ARG OCI_URL
ARG OCI_VENDOR
ARG OCI_LICENSES

LABEL org.opencontainers.image.created=$OCI_CREATED \
org.opencontainers.image.title=$OCI_TITLE \
org.opencontainers.image.description=$OCI_DESCRIPTION \
org.opencontainers.image.source=$OCI_SOURCE \
org.opencontainers.image.revision=$OCI_REVISION \
org.opencontainers.image.version=$OCI_VERSION \
org.opencontainers.image.url=$OCI_URL \
org.opencontainers.image.vendor=$OCI_VENDOR

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app/build

EXPOSE 3000
CMD ["node", "build"]

What to adapt:

SituationChange
No pnpm-workspace.yaml in the repoRemove it from both COPY lines (the build fails on a missing file).
NestJSFinal stage copies dist/ instead of build/, plus prisma/, prisma.config.ts and any runtime dirs (public/, views/). CMD ["node", "dist/src/main.js"] or dist/main.js, whichever pnpm build produces. Run pnpm exec prisma generate in the deps stage after install.
Memory-hungry SSRCMD ["node", "--max-old-space-size=384", "build"] (see oym-paddock-fe) and raise resources in step 5.
pnpm complains "verify-deps"Add ENV CI=true in base and pnpm install --frozen-lockfile --offline && pnpm run build in the build stage (see runningcoach-dashboard).

.deploy/docker/build/app.Dockerfile.dockerignore (same for every Node project):

Name it after the Dockerfile

Docker only reads a plain .dockerignore at the context root (the repo root), never one inside .deploy/docker/build/. BuildKit does read <Dockerfile>.dockerignore next to the Dockerfile, so the file must be called app.Dockerfile.dockerignore. With the wrong name the build context silently includes node_modules, .git and any local .env, and COPY . . puts them in the image.

.git
.gitignore
.gitlab
.gitlab-ci.yml
node_modules
.pnpm-store
vendor
*.log
.svelte-kit
build
dist
coverage
playwright-report
test-results
.env
.env.*
*.local
.vscode
.idea
.DS_Store
*.md
docker-compose.yml
.ddev

Do not list .deploy: the Dockerfile copies the Caddyfile and ini from there. vendor matters for PHP projects: a stale local vendor/ copied over the image's fresh composer install breaks the autoloader. Add the project's own build outputs too (public/build, public/cache, storage/runtime), they are rebuilt or mounted.

php-generic (Craft CMS, api-simple)​

The Dockerfile has four stages on top of dunglas/frankenphp:php${PHP_VERSION}:

  1. base: apt packages + PHP extensions (intl pdo pdo_mysql mysqli zip gd bcmath + redis, imagick via pecl), production php.ini, your php-override.ini, composer binary.
  2. deps: composer install --no-dev --prefer-dist --no-scripts --no-autoloader from composer.lock.
  3. build: installs Node + pnpm, pnpm install --frozen-lockfile, pnpm build, then composer dump-autoload --optimize. Do not add --classmap-authoritative: it disables the PSR-4 fallback, and plugins such as Linkit ship legacy-namespace shim files that declare no class and only load through that fallback. Skip the Node part if there is no frontend build (ziggo-espn-quiz-be).
  4. runner: COPY --from=build /app /app, the Caddyfile, EXPOSE 80, CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"].

Copy it from the reference project and only touch: the extension list (drop imagick/bcmath if unused), the Node stage (keep or drop), and writable directories (RUN mkdir -p cache && chown -R www-data:www-data cache).

Caddyfile, minimal (api-simple):

:80

root * /app/public
encode gzip

php_server {
try_files {path} {path}/index.php =404
}

file_server

Caddyfile, Craft CMS (probes + Craft rewrite; from nn-supportervanallehardlopers, without the project-specific redirects):

:80 {
root * /app/public
encode gzip zstd

handle /health {
respond "OK" 200
}

handle /ready {
rewrite * /ready.php
php_server
}

handle {
@craft {
not file
not path /favicon.ico
}
rewrite @craft /index.php?p={path}&{query}

php_server
file_server
}
}

php-override.ini, minimum:

variables_order = "EGPCS"
expose_php = Off
display_errors = Off
display_startup_errors = Off
; Upload progress is unused and lets any POST write into the session file (LFI/RCE primitive).
session.upload_progress.enabled = Off

Add memory_limit, upload_max_filesize, post_max_size as the project needs (onyourmarks-website uses 512M / 70M / 70M).

Craft projects also need .container-init.sh (runs on every pod start, before traffic):

#!/usr/bin/env sh
set -e
composer run-script post-autoload-dump

and keep public/cache on a shared volume (step 5), because the build wrote nothing there and pods must agree. php craft up and cacheable/flush/config are not in the script: they run once per deploy from component-tasks.migrations (step 5), before the pods roll.

symfony​

Same four stages as php-generic (copy from wedstrijd-van-je-leven). Differences: symfony.lock is copied with the composer files; no php-override.ini but two runtime files copied in the last stage:

COPY .deploy/docker/runtime/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY .deploy/docker/runtime/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini
RUN mkdir -p var/cache var/log /tmp/opcache && chown -R www-data:www-data var /tmp/opcache

php.ini must also carry session.upload_progress.enabled = Off (see the php-override.ini minimum above; same reason). opcache.ini enables preloading (opcache.preload = /app/config/preload.php), so config/preload.php must exist. Remember from step 1: the symfony chart always probes, so add the /health and /ready handlers to the Caddyfile.

Test the image locally​

Do this before pushing. It catches 90% of CI failures in a minute.

# build exactly like CI does
docker build \
--file .deploy/docker/build/app.Dockerfile \
--build-arg NODE_VERSION=24 --build-arg PNPM_VERSION=11 --build-arg PHP_VERSION=8.5 \
--tag <slug>:local .

# run it with your local env (Node: port 3000, PHP: port 80)
docker run --rm -p 3000:3000 --env-file .env <slug>:local
# PHP: docker run --rm -p 8080:80 --env-file .env <slug>:local

# in another terminal
curl -i localhost:3000/health
curl -i localhost:3000/ready

The DB in .env points at ddev's db host, which the container can't reach, so /ready may return 503 locally. /health must be 200 and the app must start without crashing.

Let Claude Code do it​

Phase 3 of the project prompt from the prompt generator copies the reference .deploy folder, adapts it and runs the local test.

Done when​

  • .deploy/docker/build/app.Dockerfile exists at exactly that path, plus app.Dockerfile.dockerignore (and Caddyfile/ini for PHP)
  • docker build succeeds with the three --build-args
  • The container starts and GET /health returns 200 on the chart's port
  • No .env, no secrets, no hardcoded versions in the Dockerfile