Skip to main content

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:

PathQuestionAnswer
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

The symfony chart cannot switch probes off

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

ChartYour app must listen on
sveltekit, node-generic3000 (read process.env.PORT, default 3000; bind 0.0.0.0)
php-generic, symfony80 (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 .env file in the image. Never COPY .env. It is in .dockerignore for a reason.
  • No secrets as Docker build args.
  • SvelteKit: use $env/dynamic/private and $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" in php-override.ini (step 3) makes $_ENV work.
  • .env.example in 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.yaml committed (pnpm only, no npm/yarn). composer.lock committed for PHP.
  • pnpm build (Node) or composer install --no-dev (PHP) must succeed with NODE_ENV=production and 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.sh at the repo root. The cluster runs it in an init container for node-generic, php-generic and symfony (not for sveltekit). Example from nn-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/health and /ready return 200 locally (or you've decided to disable probes, php-generic only)
  • App reads all config from env at runtime; .env.example is complete
  • pnpm build / composer install --no-dev works
  • .container-init.sh exists if you have migrations