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/.dockerignore ← always
.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 (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
| Rule | Why |
|---|---|
First line # syntax=docker/dockerfile:1.7 | Enables cache mounts used below. |
ARG NODE_VERSION, ARG PNPM_VERSION, ARG PHP_VERSION and use them in FROM | CI passes these (24, 11, 8.5). Never hardcode a version. |
The OCI_* ARG/LABEL block in the final stage | CI 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=prod | Production build. |
No COPY .env, no secrets as build args | Config comes from Infisical at runtime. |
Lockfile-driven installs (pnpm install --frozen-lockfile, composer install from composer.lock) | Reproducible builds. |
CMD [...] in exec form, no ENTRYPOINT | The chart overrides command: for cron/workers. |
Node images: COPY .container-init.sh into the final stage if you have one | The init container runs it from the image. |
Copy from
| Chart / stack | Copy .deploy/ from | Also copy |
|---|---|---|
sveltekit | ziggo-espn-quiz-fe | – |
node-generic, NestJS + Prisma | nn-backstory | .container-init.sh |
node-generic, Node without a build step | hardloopsupporter-api | – |
php-generic, Craft CMS | onyourmarks-website | public/ready.php, .container-init.sh |
php-generic, api-simple | ziggo-espn-quiz-be (no frontend build) | – |
symfony | wedstrijd-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 \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile
FROM base AS build
COPY /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 /app/node_modules /app/node_modules
COPY /app/build /app/build
EXPOSE 3000
CMD ["node", "build"]
What to adapt:
| Situation | Change |
|---|---|
No pnpm-workspace.yaml in the repo | Remove it from both COPY lines (the build fails on a missing file). |
| NestJS | Final stage copies dist/ instead of build/, plus .container-init.sh, 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 SSR | CMD ["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/.dockerignore (same for every Node project):
.git
.gitignore
.gitlab
.gitlab-ci.yml
node_modules
.pnpm-store
*.log
.svelte-kit
build
dist
coverage
playwright-report
test-results
.env
.env.*
*.local
.vscode
.idea
.DS_Store
*.md
docker-compose.yml
.ddev
.deploy
php-generic (Craft CMS, api-simple)
The Dockerfile has four stages on top of dunglas/frankenphp:php${PHP_VERSION}:
base: apt packages + PHP extensions (intl pdo pdo_mysql mysqli zip gd bcmath+redis,imagickvia pecl), productionphp.ini, yourphp-override.ini, composer binary.deps:composer install --no-dev --prefer-dist --no-scripts --no-autoloaderfromcomposer.lock.build: installs Node + pnpm,pnpm install --frozen-lockfile,pnpm build, thencomposer dump-autoload --optimize --classmap-authoritative. Skip the Node part if there is no frontend build (ziggo-espn-quiz-be).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
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
php ./craft up
php ./craft cacheable/flush/config
and keep public/cache on a shared volume (step 5), because the build wrote nothing there and pods must agree.
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
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.Dockerfileexists at exactly that path, plus.dockerignore(and Caddyfile/ini for PHP) -
docker buildsucceeds with the three--build-args - The container starts and
GET /healthreturns 200 on the chart's port - No
.env, no secrets, no hardcoded versions in the Dockerfile