Step 1 — Make the project container-ready
Before any Dockerfile or CI, the application itself has to behave like a container citizen. Five things, all in the project repo.
1.1 The repo lives in the right place
gitlab.com/onyourmarks/k8s/<slug>. If the project is somewhere else (another group, a personal namespace), move it
first: GitLab → project → Settings → General → Advanced → Transfer project. The group-level CI variables that
the pipeline needs only exist on onyourmarks/k8s.
The project name in GitLab must equal the slug, because CI uses CI_PROJECT_NAME to tell Kargo which
warehouse to refresh.
1.2 Health endpoints
Kubernetes asks your app two questions, over plain HTTP, on the container port:
| Path | Question | Answer |
|---|---|---|
GET /health | "Is the process alive?" | 200 always, no I/O. If this fails 3 times the pod is killed and restarted. A DB hiccup must not restart your app. |
GET /ready | "Can this pod serve traffic right now?" | 200 when the critical dependency (usually the DB) answers; 503 otherwise. A failing pod is taken out of the load balancer, not restarted. |
Both must respond within 2 seconds.
SvelteKit
src/routes/health/+server.ts:
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
};
src/routes/ready/+server.ts: same file with { status: 'ready' }. If the app talks to a database (Prisma), do a
real check like racingnews-charlie does:
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
export const GET: RequestHandler = async () => {
try {
await db.$queryRaw`SELECT 1`;
return json({ status: 'ready' });
} catch (error) {
return json({ status: 'not ready' }, { status: 503 });
}
};
Make sure any auth or locale hook in src/hooks.server.ts lets /health and /ready through unauthenticated.
NestJS
One controller at the app root, no prefix. Reference: runningcoach-agent/src/health/health.controller.ts.
import { Controller, Get, HttpCode, HttpStatus, Res } from '@nestjs/common';
import type { Response } from 'express';
import { PrismaService } from '../persistence/prisma.service';
@Controller()
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get('health')
@HttpCode(HttpStatus.OK)
live(): { status: 'ok' } {
return { status: 'ok' };
}
@Get('ready')
async ready(@Res() res: Response): Promise<void> {
try {
await this.prisma.$queryRaw`SELECT 1`;
res.status(HttpStatus.OK).json({ status: 'ready', checks: { db: 'ok' } });
} catch (err) {
res
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: 'not_ready', checks: { db: `fail: ${(err as Error).message}` } });
}
}
}
If you use a global prefix (app.setGlobalPrefix('api')), exclude these two routes from it.
Express (no framework)
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }));
app.get('/ready', (req, res) => res.status(200).json({ status: 'ready' }));
Craft CMS (php-generic)
Two pieces. Caddy answers /health itself and routes /ready to a tiny PHP file that never boots Craft.
In .deploy/docker/build/Caddyfile (you'll create it in step 3; note it now):
handle /health {
respond "OK" 200
}
handle /ready {
rewrite * /ready.php
php_server
}
public/ready.php (copy from onyourmarks-website/public/ready.php):
<?php
declare(strict_types=1);
header('Content-Type: application/json');
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s',
getenv('CRAFT_DB_SERVER') ?: '127.0.0.1',
getenv('CRAFT_DB_PORT') ?: '3306',
getenv('CRAFT_DB_DATABASE') ?: ''
);
try {
$pdo = new PDO($dsn, getenv('CRAFT_DB_USER') ?: '', getenv('CRAFT_DB_PASSWORD') ?: '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 2,
]);
$pdo->query('SELECT 1')->fetchColumn();
http_response_code(200);
echo json_encode(['status' => 'ready']);
} catch (Throwable $e) {
http_response_code(503);
echo json_encode(['status' => 'unavailable']);
}
api-simple / plain PHP
Existing api-simple projects (ziggo-espn-quiz-be, eliuds-running-world-api) skip the endpoints and switch the
probes off in the cluster values file (step 5). That's acceptable for small APIs. Prefer adding the two Caddy
handle blocks above; a static 200 on /ready is still better than no readiness at all.
Symfony
Unlike php-generic, the symfony chart always probes /health and /ready. Either add the two Caddy handle
blocks (static responses are fine), or deploy with the php-generic chart instead.
1.3 Port
| Chart | Your app must listen on |
|---|---|
sveltekit, node-generic | 3000 (read process.env.PORT, default 3000; bind 0.0.0.0) |
php-generic, symfony | 80 (Caddy inside FrankenPHP, configured in the Caddyfile) |
Nothing sets PORT in the cluster, so the default is what runs. SvelteKit's adapter-node defaults to 3000 already.
1.4 Configuration is runtime-only
Pods receive all configuration as environment variables from one Kubernetes Secret (app-secrets), filled from
Infisical. Consequences:
- No
.envfile in the image. NeverCOPY .env. It is in.dockerignorefor a reason. - No secrets as Docker build args.
- SvelteKit: use
$env/dynamic/privateand$env/dynamic/public, never$env/static/*. Static env is frozen at build time, and the image is built once for all environments. - Craft / PHP: read via
getenv()/App::env();variables_order = "EGPCS"inphp-override.ini(step 3) makes$_ENVwork. .env.examplein the repo lists every variable the app needs, with dummy values. It doubles as the checklist for step 2.
1.5 Lockfiles and build
pnpm-lock.yamlcommitted (pnpm only, no npm/yarn).composer.lockcommitted for PHP.pnpm build(Node) orcomposer install --no-dev(PHP) must succeed withNODE_ENV=productionand no network secrets. If your build needs a private registry token, stop and talk to DevOps.- Anything that has to run before the app starts on every deploy (Prisma migrations,
php craft up) goes into./.container-init.shat the repo root. The cluster runs it in an init container fornode-generic,php-genericandsymfony(not forsveltekit). Example fromnn-backstory:
#!/usr/bin/env sh
set -e
./node_modules/.bin/prisma migrate deploy
Let Claude Code do it
Phase 1 of the project prompt from the prompt generator does all of the above.
Done when
- Project is at
gitlab.com/onyourmarks/k8s/<slug>and the GitLab project name equals the slug -
curl localhost:3000/healthand/readyreturn 200 locally (or you've decided to disable probes, php-generic only) - App reads all config from env at runtime;
.env.exampleis complete -
pnpm build/composer install --no-devworks -
.container-init.shexists if you have migrations