Aller au contenu

Skills Celuga

celuga-docker-payload-astro

Images Docker Bun, stack Compose/Coolify, migrations ponctuelles, durcissement, diagnostics de santé.

Quand : Images Docker Bun, stack Compose/Coolify, migrations ponctuelles, durcissement, diagnostics de santé.

Portée : machine (~/.agents/skills) · Fichiers : 7

Copier le bloc et le coller dans un terminal. Il recrée tous les fichiers du skill.

dir="$HOME/.agents/skills/celuga-docker-payload-astro"
mkdir -p "$dir"
cat > "$dir/SKILL.md" <<'CELUGA_SKILL_EOF'
---
name: celuga-docker-payload-astro
description: Créer, porter ou auditer les images Docker Bun et la stack Compose/Coolify Payload 4 (TanStack Start) + Astro SSR, avec distinction build/runtime, origines, réseaux, persistance, secrets, migrations ponctuelles, durcissement et diagnostics de santé.
---
# Docker Payload + Astro
Partir des consommateurs du dépôt et distinguer le déploiement **observé** d'une cible **recommandée**. Un Compose valide ne prouve ni les migrations, ni la connectivité, ni la fraîcheur du cache. Un audit n'autorise pas un déploiement ou une mutation de base.
| Besoin | Ressource |
| --- | --- |
| images, variables et topologie cibles | [contrat](references/coolify-contract.md) |
| échec de build, DNS, santé, cache | [diagnostics](references/diagnostics.md) |
| nouvelle stack | [template Compose](assets/docker-compose.coolify.yaml) |
| audit statique sans lecture des secrets | `bun <skill-dir>/scripts/audit-compose.mjs <compose>` |
## Cartographier
Relever scripts, runtime réel, build args, variables runtime, chemins des volumes, alias, sorties de build et routes de santé. Lire les gardes de configuration avant de proposer une variable.
Noms : `PUBLIC_PAYLOAD_URL`, `PUBLIC_SITE_URL`, `PAYLOAD_INTERNAL_URL`, `WEB_INTERNAL_URL`, `SITE_SECRET`. Les `PUBLIC_*` passent au build du Web : les changer exige un rebuild.
## Images cibles
Bun seul, version fixée, Alpine, utilisateur non root `bun`. Aucun binaire Node : `node` y désigne un lien vers Bun. Cache Bun BuildKit, lockfile gelé, linker `hoisted`, valeurs factices au build et aucun vrai secret dans les couches.
- **CMS** : Vite produit `dist/client` et `dist/server`, servis par `apps/cms/server.mjs` sur `srvx`. `trace-runtime.ts` copie seulement les fichiers de `node_modules` que le serveur peut charger. Un module chargé par un nom calculé échappe au tracé : un démarrage de l'image le révèle.
- **tools** : installation de production élaguée, pour les commandes Payload ponctuelles (`migrate`, `admin:create`), sous profil Compose.
- **Web** : `ASTRO_BUNDLE_DEPS=true` au seul build d'image ; le runner ne copie que `dist`, le package et `server.mjs`, sans `node_modules`.
Start Command de Coolify vide : chaque image a son CMD.
## Stack
`cms`, `web` et `migrate` (profil `tools`). Fichier principal sans port publié ; overrides pour le local (`compose.local.yaml`), Coolify (`compose.coolify.yaml`, réseau externe) et PostgreSQL dans la stack (`compose.postgres.yaml`).
- Réseau privé nommé, alias propres au projet (`<projet>-cms`, `<projet>-web`) : un alias générique `cms` entre en collision sur un réseau partagé.
- `depends_on: cms: { condition: service_healthy }`.
- Sondes `wget` de BusyBox : CMS sur `/admin` et `/api/health` (requête PostgreSQL réelle et tâches de fond), Web sur son `/api/health`, qui appelle celui du CMS. `start_period` généreux et `start_interval` court. Un simple `robots.txt` ne prouve pas la santé du site.
- Volume uploads sur le **seul** CMS : le Web passe par le proxy. Les fichiers privés y restent.
- Durcissement : `init: true` (sans lui, le serveur en PID 1 ignore SIGTERM), `cap_drop: [ALL]`, `no-new-privileges`, journaux `10m` × 3.
## Données et cache
`DATABASE_URL` et `PAYLOAD_SECRET` sur le CMS et `migrate` uniquement, jamais dans le service Web ni ses arguments de build. `SITE_SECRET` identique sur CMS et Web.
**Le CMS n'applique aucune migration au démarrage.** Après sauvegarde et relecture : `docker compose run --build --rm --no-deps migrate`. Ne pas lancer de migration sur une base réelle pour terminer un audit.
Une instance CMS (tâches de fond) et une instance Web (cache mémoire). Détails : **celuga-astro-payload-cache**.
## Validation proportionnée
Après modification locale : audit statique, puis `docker compose config --quiet` avec un environnement de test sans secrets réels. Fixtures négatives : `bun <skill-dir>/scripts/test-audit.mjs`. L'audit ne résout ni les variables ni les états de la plateforme.
Pour un déploiement autorisé : construire, migrer, démarrer, vérifier santé et alias depuis les conteneurs, routes publiques, médias, preview, sitemap et invalidation. Ne jamais afficher l'environnement complet, les URL de base ou les secrets ; ne pas supprimer de volume sans autorisation explicite.
CELUGA_SKILL_EOF
mkdir -p "$dir/agents"
cat > "$dir/agents/openai.yaml" <<'CELUGA_SKILL_EOF'
interface:
display_name: "Celuga Docker Payload Astro"
short_description: "Déployer Payload + Astro sur Coolify avec des healthchecks causaux"
default_prompt: "Utilise $celuga-docker-payload-astro pour créer ou auditer le déploiement Docker Compose Coolify Payload + Astro de ce projet."
CELUGA_SKILL_EOF
mkdir -p "$dir/assets"
cat > "$dir/assets/docker-compose.coolify.yaml" <<'CELUGA_SKILL_EOF'
# Modèle : adapter my-project, puis les chemins et routes de sonde aux consommateurs réels.
# Fichier principal sans port publié. Coolify : ajouter un override qui rattache cms, migrate et web
# au réseau externe, laisser la Start Command vide et affecter les domaines aux ports 3000 et 4321.
name: my-project
services:
cms:
build:
context: .
dockerfile: Dockerfile.cms
environment: &cms-environment
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:?URL PostgreSQL requise}
PAYLOAD_SECRET: ${PAYLOAD_SECRET:?Secret Payload requis}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
SITE_SECRET: ${SITE_SECRET:?Secret des échanges CMS-Web requis}
WEB_INTERNAL_URL: http://my-project-web:4321
EMAIL_HOST: ${EMAIL_HOST:?Serveur SMTP requis}
EMAIL_USER: ${EMAIL_USER:?Adresse mail SMTP requise}
EMAIL_PASS: ${EMAIL_PASS:-}
volumes: ["uploads:/app/apps/cms/storage"]
networks:
private:
aliases: ["my-project-cms"]
expose: ["3000"]
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
healthcheck:
test: ["CMD-SHELL", "wget -qO/dev/null -T 8 http://127.0.0.1:3000/admin && wget -qO/dev/null -T 8 http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
start_interval: 2s
migrate:
profiles: [tools]
build:
context: .
dockerfile: Dockerfile.cms
target: tools
environment:
<<: *cms-environment
volumes: ["uploads:/app/apps/cms/storage"]
networks: [private]
command: ["bun", "run", "migrate"]
web:
build:
context: .
dockerfile: Dockerfile.web
args:
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
SITE_SECRET: ${SITE_SECRET:?Secret des échanges CMS-Web requis}
PAYLOAD_INTERNAL_URL: http://my-project-cms:3000
networks:
private:
aliases: ["my-project-web"]
expose: ["4321"]
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
depends_on:
cms: { condition: service_healthy }
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
healthcheck:
test: ["CMD", "wget", "-qO/dev/null", "-T", "8", "http://127.0.0.1:4321/api/health"]
interval: 20s
timeout: 10s
retries: 5
start_period: 30s
start_interval: 2s
networks:
private:
name: my-project-private
volumes:
uploads:
CELUGA_SKILL_EOF
mkdir -p "$dir/references"
cat > "$dir/references/coolify-contract.md" <<'CELUGA_SKILL_EOF'
# Contrat Docker/Coolify
## Images
| Image | Étapes et runtime |
| --- | --- |
| CMS (`Dockerfile.cms`) | `base → deps → builder` ; `prod-deps → tools-deps → tools` ; `runner` ; CMD `bun server.mjs`, précédé au besoin d'une copie des médias de seed |
| Web (`Dockerfile.web`) | `base → deps → builder → runner` ; CMD `bun server.mjs` ; `TRUST_PROXY=true` |
- `base` copie manifestes, `bun.lock`, `bunfig.toml`, `patches` et ce que le `postinstall` exige. `bun install --frozen-lockfile --filter <app>`.
- Le build du CMS reçoit `DATABASE_URL`, `PAYLOAD_SECRET`, `SITE_SECRET` et e-mail **factices** ; il régénère types et import map.
- `trace-runtime.ts` (`@vercel/nft`) produit `standalone/node_modules`. Les binaires Sharp glibc sont retirés sur Alpine ; vérifier `require("sharp")` dans l'étape `deps`.
- `tools-deps` retire dans une étape séparée ce que le CLI Payload ne charge pas, pour que ces fichiers ne restent pas dans une couche.
- `.dockerignore` exclut `.env`, sorties, `node_modules`, stockage et rapports.
- `apps/cms/server.mjs` : fichiers précompressés, cache d'un an sur `assets/`, `bun: { idleTimeout: 120 }`. Par défaut, Bun coupe après une dizaine de secondes un client muet, comme un envoi de média figé.
- Pas de retest utile : `bun --smol` (peu de mémoire gagnée, pages en cache deux fois plus lentes). Le gain d'image vient surtout de la trace et de l'élagage ; mesurer la mémoire après activité, pas seulement au démarrage.
## Variables
| Variable | Quand / usage |
| --- | --- |
| `PUBLIC_PAYLOAD_URL`, `PUBLIC_SITE_URL` | build Web et runtime CMS/Web ; origines HTTPS complètes |
| `PUBLIC_ALLOW_INDEXING` | build Web et runtime ; défaut `false` |
| `PAYLOAD_INTERNAL_URL` | runtime Web, alias privé fixé par Compose |
| `WEB_INTERNAL_URL` | runtime CMS, alias privé fixé par Compose |
| `SITE_SECRET` | runtime CMS/Web, `${VAR:?}` |
| `DATABASE_URL`, `PAYLOAD_SECRET` | runtime CMS et `migrate` seulement, `${VAR:?}` |
| `EMAIL_HOST`, `EMAIL_USER`, `EMAIL_PASS` | runtime CMS ; `EMAIL_PASS` facultatif |
Compose référence explicitement chaque variable : une option absente du fichier n'arrive pas au conteneur, même présente dans le `.env`. Pour une option facultative, ajouter un override. Pas d'`env_file` d'application.
## Topologie
Pas de `ports` dans le fichier principal, pas de `container_name`. Le nom du projet et du réseau privé sont fixés ; une seconde installation sur le même serveur adapte noms et URL internes par override.
Sur Coolify : `compose.coolify.yaml` rattache les services au réseau externe, domaines affectés aux ports internes 3000 et 4321. PostgreSQL d'une autre ressource : rattacher CMS et `migrate` à son réseau. `localhost` désigne le conteneur.
Un seul service `migrate` ponctuel sous profil `tools` : il ne reste pas actif, et ni les réplicas ni le démarrage ne migrent.
## Santé
CMS : `wget` sur `/admin` et `/api/health`, 30 s, trois échecs, `start_period` de 90 s, `start_interval` de 2 s (Docker Engine 25+). `/api/health` échoue aussi après trois minutes sans boucle de fond réussie. Les tâches de fond démarrent à l'initialisation de Payload, donc à la première requête, souvent la sonde.
Web : `wget` sur `/api/health`, qui prouve le chemin vers le CMS. Un état `unhealthy` seul ne redémarre pas un conteneur : configurer une alerte.
Ces sondes prouvent des chemins locaux, pas le DNS ni le TLS du proxy extérieur. Tester ceux-ci séparément.
## Persistance et schéma
Volume uploads sur `/app/apps/cms/storage`, côté CMS et `migrate`. Si des médias sont versionnés pour le serveur, un script au démarrage complète le volume sans écraser, sauf empreinte déclarée comme remplacée.
Sauvegarde : `pg_dump --format=custom` et volume uploads, en cohérence, gérés sur l'hébergement. Tester la restauration dans une base distincte.
Première installation : `docker compose run --rm --no-deps -e FIRST_ADMIN_EMAIL -e FIRST_ADMIN_PASSWORD migrate bun run admin:create`, depuis l'environnement du terminal. La création publique du premier compte est refusée en production.
## Adresse réelle des visiteurs
`TRUST_PROXY=true` et liste vide : seuls les CIDR privés réellement attachés au conteneur sont fiables. Publier le port du site impose de restreindre `TRUSTED_PROXY_IPS`. Vérifier derrière le proxy que deux IP clientes ont chacune leur quota.
## Validation
`audit-compose.mjs` : signaux statiques. `docker compose config --quiet` : syntaxe et résolution, avec valeurs de test. Un build complet sous émulation amd64 sur Mac peut échouer sans rapport avec le projet : valider x64 sur le runner seul ou sur le serveur réel, et surveiller le premier déploiement.
`docker compose up` utilise les images déjà taguées si rien ne force le build : reconstruire avant une recette, sinon elle teste d'anciennes images.
CELUGA_SKILL_EOF
mkdir -p "$dir/references"
cat > "$dir/references/diagnostics.md" <<'CELUGA_SKILL_EOF'
# Diagnostic par preuve
Lire le symptôme et le code de la version déployée avant de changer une configuration. Les commandes sont des sondes à adapter ; elles n'autorisent ni déploiement ni mutation.
## Build
- Archive npm indisponible : vérifier registre, disque et cache avant de toucher au lockfile.
- Sharp introuvable : vérifier `require("sharp")` dans l'étape `deps` et les paquets musl verrouillés. Pas de `bun add` au build.
- Erreur de base ou de secret au build : Payload charge sa configuration ; les `ENV` factices doivent couvrir chaque garde, 32 caractères pour les secrets.
- `rolldown`/`wasm-simd` sous émulation amd64 : limite de l'émulation, pas du projet.
## Démarrage du CMS
- `Cannot find module` au démarrage de l'image : module chargé par un nom calculé, absent du tracé : l'ajouter aux entrées de `trace-runtime.ts`. Ne pas retirer un paquet du runner sur la seule foi de son nom.
- `Invalid URL` et conteneur `unhealthy` : une origine `PUBLIC_*` sans `https://`.
- `La variable … est obligatoire` : garde volontaire. Vérifier que Compose la transmet.
- « Schéma PostgreSQL incompatible » ou lectures en erreur : migrations non appliquées. Relire et appliquer sur la cible désignée, jamais de push.
Lire les logs runtime, sans dump d'environnement. Une `AbortError` suivie d'un 500 lors d'une déconnexion client est un bruit connu sous Bun.
## Web vivant mais contenu absent
```bash
docker compose ps
docker compose logs --tail=100 web
docker compose exec web wget -qO- http://127.0.0.1:4321/api/health
```
Cette sonde suit le chemin Web → CMS → PostgreSQL sans afficher d'URL ni de secret. Une 503 sur une page froide est le chemin explicite d'erreur ; une 502 vient souvent du proxy. Une page chaude peut rester servie pendant une panne du CMS.
## DNS et origines
Vérifier depuis le Web l'hôte réellement injecté ; un alias générique peut entrer en collision sur un réseau commun. Un nom qui ne résout pas et un port fermé sont des causes différentes. Si une URL publique change, rebuild du Web. Si d'anciennes variables sont injectées, chercher leur consommateur.
## Cache
```bash
curl -sS -D - -o /dev/null https://example.com/page
```
Observer l'en-tête de cache Astro sur une route cacheable : MISS, HIT, puis MISS après invalidation ciblée. La preview ne doit jamais être stockée.
Publication visible dans le CMS mais page obsolète : même `SITE_SECRET` des deux côtés (rapporter seulement l'égalité), `WEB_INTERNAL_URL`, journaux d'invalidation, puis tag consommé et émis. La tâche durable reprend après coup ; le filet de cinq minutes s'applique sinon. Plusieurs processus à mémoire indépendante expliquent une fraîcheur intermittente. Un redémarrage qui vide la mémoire ne prouve pas que le graphe est réparé.
## Volumes et médias
`/media/*` en 500 alors que la base référence les fichiers : volume vide. Un média envoyé depuis un CMS local sur une base partagée n'arrive pas seul sur le serveur. Vérifier montages et droits de l'utilisateur `bun`. Ne pas réinitialiser la base ni supprimer un volume pour diagnostiquer un 404.
## Arrêt lent
Un conteneur qui attend son délai avant d'être tué : `init: true` manquant.
CELUGA_SKILL_EOF
mkdir -p "$dir/scripts"
cat > "$dir/scripts/audit-compose.mjs" <<'CELUGA_SKILL_EOF'
#!/usr/bin/env bun
// Read-only structural checks for the Compose shape used by Celuga projects.
// Run docker compose config --quiet separately: this scanner is not a general YAML parser.
import fs from 'node:fs'
import path from 'node:path'
const input = process.argv[2] ?? 'compose.yaml'
const file = input === '-' ? '<stdin>' : path.resolve(input)
let source
try { source = fs.readFileSync(input === '-' ? 0 : file, 'utf8') }
catch { console.error('[FAIL] Compose missing or unreadable'); process.exit(1) }
const failures = [], warnings = [], passes = []
const check = (condition, message, level = failures) => {
if (condition) passes.push(message)
else level.push(`Missing or invalid: ${message}`)
}
// Ignore full-line comments so commented examples cannot satisfy a required service/setting.
const lines = source.split(/\r?\n/).filter((line) => !/^\s*#/.test(line))
const section = (key, indent, from = lines) => {
const start = from.findIndex((line) => line === `${' '.repeat(indent)}${key}:`)
if (start < 0) return ''
let end = start + 1
while (end < from.length && (!from[end].trim() || from[end].search(/\S/) > indent)) end++
return from.slice(start + 1, end).join('\n')
}
const services = section('services', 0)
const serviceLines = services.split('\n')
const cms = section('cms', 2, serviceLines)
const web = section('web', 2, serviceLines)
const migrate = section('migrate', 2, serviceLines)
const networks = section('networks', 0)
check(Boolean(services), 'services block present')
check(Boolean(cms), 'cms service present')
check(Boolean(web), 'web service present')
check(Boolean(migrate) && /profiles:/.test(migrate), 'one-off migrate service under a profile', warnings)
const valueOf = (block, name) => block.match(new RegExp(`^\\s+${name}:[ \\t]*(.+)$`, 'm'))?.[1]?.trim() ?? ''
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, '$2')
const internal = unquote(valueOf(web, 'PAYLOAD_INTERNAL_URL'))
check(Boolean(internal), 'web declares PAYLOAD_INTERNAL_URL')
if (internal.startsWith('${')) warnings.push('PAYLOAD_INTERNAL_URL is interpolated; resolved host/network must be checked on the platform')
else if (/^https?:\/\/[^/:]*\$\{/.test(internal)) {
// Host built from the project name, e.g. http://${COMPOSE_PROJECT_NAME}-cms:3000
check(cms.includes(internal.replace(/^https?:\/\//, '').replace(/:\d+.*$/, '')), 'CMS alias declared (inspect network scope manually)')
} else if (internal) {
try {
const url = new URL(internal)
check(!url.username && !url.password, 'internal URL contains no credentials')
check(['http:', 'https:'].includes(url.protocol), 'internal URL uses HTTP(S)')
if (url.protocol === 'https:') warnings.push('Internal fetch uses public HTTPS path; inspect proxy/TLS dependency')
else {
check(!['cms', 'payload', 'api', 'backend', 'localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname), 'internal CMS host is project-specific')
check(!/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname), 'internal CMS host is not a fixed container IP')
check(cms.includes(url.hostname), 'CMS alias declared (inspect network scope manually)')
}
} catch { failures.push('PAYLOAD_INTERNAL_URL is not a valid URL (value redacted)') }
}
check(Boolean(valueOf(cms, 'WEB_INTERNAL_URL')), 'cms declares WEB_INTERNAL_URL', warnings)
for (const [name, block] of [['cms', cms], ['web', web]]) {
const health = section('healthcheck', 4, block.split('\n'))
check(Boolean(health), `${name} explicit healthcheck present`)
check(/\/api\/health/.test(health), `${name} healthcheck reaches /api/health`, warnings)
if (health && !/start_period:/.test(health)) warnings.push(`${name} healthcheck has no start_period`)
}
check(/depends_on:\s*\n\s+cms:\s*(?:\{\s*condition:\s*service_healthy|\n\s+condition:\s*service_healthy)/.test(web), 'web waits for cms service_healthy')
check(/^ {2}\w[\w-]*:/m.test(networks), 'explicit private network declared', warnings)
if (/container_name:/.test(services)) failures.push('Fixed container_name prevents independent parallel stacks')
const secretIn = (block, service, name) => {
const value = unquote(valueOf(block, name))
check(Boolean(value), `${service} declares ${name}`)
if (value && !value.startsWith('${')) failures.push(`${service} ${name} contains a literal value (redacted)`)
else if (value && !value.includes(':?')) warnings.push(`${service} ${name} not required at Compose interpolation; verify runtime validation`)
}
for (const name of ['DATABASE_URL', 'PAYLOAD_SECRET', 'SITE_SECRET']) secretIn(cms, 'cms', name)
secretIn(web, 'web', 'SITE_SECRET')
for (const name of ['DATABASE_URL', 'PAYLOAD_SECRET']) if (new RegExp(`\\b${name}\\b`).test(web)) failures.push(`web must not receive ${name}`)
if (/SITE_SECRET:[^\n]*PAYLOAD_SECRET/.test(services)) failures.push('Site secret falls back to the Payload secret')
if (/CACHE_SECRET|PREVIEW_SECRET|FORM_SECRET|CACHE_INVALIDATION_SECRET|PAYLOAD_PUBLIC_SERVER_URL|PAYLOAD_PUBLIC_SITE_URL/.test(services)) warnings.push('Legacy variable names found; check what the code actually reads')
for (const name of ['PUBLIC_PAYLOAD_URL', 'PUBLIC_SITE_URL']) check(Boolean(valueOf(cms, name)) && Boolean(valueOf(web, name)), `${name} declared for CMS and Web`)
check(Boolean(valueOf(section('args', 6, section('build', 4, web.split('\n')).split('\n')), 'PUBLIC_SITE_URL')), 'web build args carry public origins')
if (/PAYLOAD_ENABLE_PUSH:\s*['"]?true/.test(services)) failures.push('Schema push enabled')
const indexing = valueOf(web, 'PUBLIC_ALLOW_INDEXING')
if (!indexing) warnings.push('No explicit indexing setting')
else if (!indexing.includes('${')) warnings.push('Indexing value is fixed; preserve intentional environment policy and rebuild for any change')
check(/uploads|storage|media/.test(section('volumes', 4, cms.split('\n'))) || /volumes:\s*\[/.test(cms), 'CMS persistent uploads volume declared')
if (/volumes:/.test(web)) warnings.push('Web mounts a volume; uploads should stay on the CMS, served through the proxy')
if (/^\s{4}ports:/m.test(services)) warnings.push('Host ports published; verify intended proxy/network exposure')
for (const [name, block] of [['cms', cms], ['web', web]]) {
check(/^\s+init:\s*true/m.test(block), `${name} init: true (SIGTERM reaches the server)`, warnings)
check(/cap_drop:/.test(block), `${name} drops Linux capabilities`, warnings)
check(/no-new-privileges/.test(block), `${name} forbids privilege escalation`, warnings)
}
if (/REPLACE_[A-Z0-9_]+/.test(lines.join('\n'))) failures.push('Unresolved template marker')
for (const [level, messages] of [['PASS', passes], ['WARN', warnings], ['FAIL', failures]]) for (const message of messages) console.log(`[${level}] ${message}`)
console.log(`${passes.length} PASS, ${warnings.length} WARN, ${failures.length} FAIL — ${file}`)
console.log('Static only: no variable resolution, Docker build, live DNS, DB, health or cache transitions were tested.')
process.exitCode = failures.length ? 1 : 0
CELUGA_SKILL_EOF
mkdir -p "$dir/scripts"
cat > "$dir/scripts/test-audit.mjs" <<'CELUGA_SKILL_EOF'
#!/usr/bin/env bun
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const script = fileURLToPath(new URL('./audit-compose.mjs', import.meta.url))
const template = fs.readFileSync(new URL('../assets/docker-compose.coolify.yaml', import.meta.url), 'utf8')
const run = (input) => spawnSync(process.execPath, [script, '-'], { input, encoding: 'utf8' })
const baseline = run(template)
assert.equal(baseline.status, 0, `template baseline\n${baseline.stdout}`)
assert.ok(!baseline.stdout.includes('[WARN]'), `template should not warn\n${baseline.stdout}`)
assert.equal(run('').status, 1, 'empty input')
const expectFailure = (source, message) => {
assert.notEqual(source, template, `fixture did not change: ${message}`)
const result = run(source)
assert.equal(result.status, 1, message)
assert.ok(result.stdout.includes(message), `${message}\n${result.stdout}`)
}
const secretRef = '${SITE_SECRET:?Secret des échanges CMS-Web requis}'
expectFailure(template.replaceAll('my-project-cms', 'cms'), 'internal CMS host is project-specific')
expectFailure(template.replace(/ SITE_SECRET:.*\n/, ''), 'cms declares SITE_SECRET')
expectFailure(template.replaceAll(secretRef, 'sensitive-value'), 'literal value (redacted)')
assert.ok(!run(template.replaceAll(secretRef, 'sensitive-value')).stdout.includes('sensitive-value'), 'literal secrets are never printed')
expectFailure(template.replace(' PAYLOAD_INTERNAL_URL:', ' DATABASE_URL: ${DATABASE_URL:?x}\n PAYLOAD_INTERNAL_URL:'), 'web must not receive DATABASE_URL')
expectFailure(template.replace(' web:', ' # web:'), 'web service present')
expectFailure(template.replaceAll('healthcheck:', 'disabled-check:'), 'cms explicit healthcheck present')
expectFailure(template.replace('cms: { condition: service_healthy }', 'cms: { condition: service_started }'), 'web waits for cms service_healthy')
console.log('PASS: template, empty/missing service, generic host, absent/literal secret redaction, secrets kept off the web, healthchecks and startup order')
CELUGA_SKILL_EOF
mkdir -p "$HOME/.claude/skills" "$HOME/.codex/skills"
[ -e "$HOME/.claude/skills/celuga-docker-payload-astro" ] || ln -s "$dir" "$HOME/.claude/skills/celuga-docker-payload-astro"
[ -e "$HOME/.codex/skills/celuga-docker-payload-astro" ] || ln -s "$dir" "$HOME/.codex/skills/celuga-docker-payload-astro"
SKILL.md
---
name: celuga-docker-payload-astro
description: Créer, porter ou auditer les images Docker Bun et la stack Compose/Coolify Payload 4 (TanStack Start) + Astro SSR, avec distinction build/runtime, origines, réseaux, persistance, secrets, migrations ponctuelles, durcissement et diagnostics de santé.
---
# Docker Payload + Astro
Partir des consommateurs du dépôt et distinguer le déploiement **observé** d'une cible **recommandée**. Un Compose valide ne prouve ni les migrations, ni la connectivité, ni la fraîcheur du cache. Un audit n'autorise pas un déploiement ou une mutation de base.
| Besoin | Ressource |
| --- | --- |
| images, variables et topologie cibles | [contrat](references/coolify-contract.md) |
| échec de build, DNS, santé, cache | [diagnostics](references/diagnostics.md) |
| nouvelle stack | [template Compose](assets/docker-compose.coolify.yaml) |
| audit statique sans lecture des secrets | `bun <skill-dir>/scripts/audit-compose.mjs <compose>` |
## Cartographier
Relever scripts, runtime réel, build args, variables runtime, chemins des volumes, alias, sorties de build et routes de santé. Lire les gardes de configuration avant de proposer une variable.
Noms : `PUBLIC_PAYLOAD_URL`, `PUBLIC_SITE_URL`, `PAYLOAD_INTERNAL_URL`, `WEB_INTERNAL_URL`, `SITE_SECRET`. Les `PUBLIC_*` passent au build du Web : les changer exige un rebuild.
## Images cibles
Bun seul, version fixée, Alpine, utilisateur non root `bun`. Aucun binaire Node : `node` y désigne un lien vers Bun. Cache Bun BuildKit, lockfile gelé, linker `hoisted`, valeurs factices au build et aucun vrai secret dans les couches.
- **CMS** : Vite produit `dist/client` et `dist/server`, servis par `apps/cms/server.mjs` sur `srvx`. `trace-runtime.ts` copie seulement les fichiers de `node_modules` que le serveur peut charger. Un module chargé par un nom calculé échappe au tracé : un démarrage de l'image le révèle.
- **tools** : installation de production élaguée, pour les commandes Payload ponctuelles (`migrate`, `admin:create`), sous profil Compose.
- **Web** : `ASTRO_BUNDLE_DEPS=true` au seul build d'image ; le runner ne copie que `dist`, le package et `server.mjs`, sans `node_modules`.
Start Command de Coolify vide : chaque image a son CMD.
## Stack
`cms`, `web` et `migrate` (profil `tools`). Fichier principal sans port publié ; overrides pour le local (`compose.local.yaml`), Coolify (`compose.coolify.yaml`, réseau externe) et PostgreSQL dans la stack (`compose.postgres.yaml`).
- Réseau privé nommé, alias propres au projet (`<projet>-cms`, `<projet>-web`) : un alias générique `cms` entre en collision sur un réseau partagé.
- `depends_on: cms: { condition: service_healthy }`.
- Sondes `wget` de BusyBox : CMS sur `/admin` et `/api/health` (requête PostgreSQL réelle et tâches de fond), Web sur son `/api/health`, qui appelle celui du CMS. `start_period` généreux et `start_interval` court. Un simple `robots.txt` ne prouve pas la santé du site.
- Volume uploads sur le **seul** CMS : le Web passe par le proxy. Les fichiers privés y restent.
- Durcissement : `init: true` (sans lui, le serveur en PID 1 ignore SIGTERM), `cap_drop: [ALL]`, `no-new-privileges`, journaux `10m` × 3.
## Données et cache
`DATABASE_URL` et `PAYLOAD_SECRET` sur le CMS et `migrate` uniquement, jamais dans le service Web ni ses arguments de build. `SITE_SECRET` identique sur CMS et Web.
**Le CMS n'applique aucune migration au démarrage.** Après sauvegarde et relecture : `docker compose run --build --rm --no-deps migrate`. Ne pas lancer de migration sur une base réelle pour terminer un audit.
Une instance CMS (tâches de fond) et une instance Web (cache mémoire). Détails : **celuga-astro-payload-cache**.
## Validation proportionnée
Après modification locale : audit statique, puis `docker compose config --quiet` avec un environnement de test sans secrets réels. Fixtures négatives : `bun <skill-dir>/scripts/test-audit.mjs`. L'audit ne résout ni les variables ni les états de la plateforme.
Pour un déploiement autorisé : construire, migrer, démarrer, vérifier santé et alias depuis les conteneurs, routes publiques, médias, preview, sitemap et invalidation. Ne jamais afficher l'environnement complet, les URL de base ou les secrets ; ne pas supprimer de volume sans autorisation explicite.
agents/openai.yaml
interface:
display_name: "Celuga Docker Payload Astro"
short_description: "Déployer Payload + Astro sur Coolify avec des healthchecks causaux"
default_prompt: "Utilise $celuga-docker-payload-astro pour créer ou auditer le déploiement Docker Compose Coolify Payload + Astro de ce projet."
assets/docker-compose.coolify.yaml
# Modèle : adapter my-project, puis les chemins et routes de sonde aux consommateurs réels.
# Fichier principal sans port publié. Coolify : ajouter un override qui rattache cms, migrate et web
# au réseau externe, laisser la Start Command vide et affecter les domaines aux ports 3000 et 4321.
name: my-project
services:
cms:
build:
context: .
dockerfile: Dockerfile.cms
environment: &cms-environment
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:?URL PostgreSQL requise}
PAYLOAD_SECRET: ${PAYLOAD_SECRET:?Secret Payload requis}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
SITE_SECRET: ${SITE_SECRET:?Secret des échanges CMS-Web requis}
WEB_INTERNAL_URL: http://my-project-web:4321
EMAIL_HOST: ${EMAIL_HOST:?Serveur SMTP requis}
EMAIL_USER: ${EMAIL_USER:?Adresse mail SMTP requise}
EMAIL_PASS: ${EMAIL_PASS:-}
volumes: ["uploads:/app/apps/cms/storage"]
networks:
private:
aliases: ["my-project-cms"]
expose: ["3000"]
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
healthcheck:
test: ["CMD-SHELL", "wget -qO/dev/null -T 8 http://127.0.0.1:3000/admin && wget -qO/dev/null -T 8 http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
start_interval: 2s
migrate:
profiles: [tools]
build:
context: .
dockerfile: Dockerfile.cms
target: tools
environment:
<<: *cms-environment
volumes: ["uploads:/app/apps/cms/storage"]
networks: [private]
command: ["bun", "run", "migrate"]
web:
build:
context: .
dockerfile: Dockerfile.web
args:
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:?Origine publique du site requise}
PUBLIC_PAYLOAD_URL: ${PUBLIC_PAYLOAD_URL:?Origine publique du CMS requise}
PUBLIC_ALLOW_INDEXING: ${PUBLIC_ALLOW_INDEXING:-false}
SITE_SECRET: ${SITE_SECRET:?Secret des échanges CMS-Web requis}
PAYLOAD_INTERNAL_URL: http://my-project-cms:3000
networks:
private:
aliases: ["my-project-web"]
expose: ["4321"]
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
depends_on:
cms: { condition: service_healthy }
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
healthcheck:
test: ["CMD", "wget", "-qO/dev/null", "-T", "8", "http://127.0.0.1:4321/api/health"]
interval: 20s
timeout: 10s
retries: 5
start_period: 30s
start_interval: 2s
networks:
private:
name: my-project-private
volumes:
uploads:
references/coolify-contract.md
# Contrat Docker/Coolify
## Images
| Image | Étapes et runtime |
| --- | --- |
| CMS (`Dockerfile.cms`) | `base → deps → builder` ; `prod-deps → tools-deps → tools` ; `runner` ; CMD `bun server.mjs`, précédé au besoin d'une copie des médias de seed |
| Web (`Dockerfile.web`) | `base → deps → builder → runner` ; CMD `bun server.mjs` ; `TRUST_PROXY=true` |
- `base` copie manifestes, `bun.lock`, `bunfig.toml`, `patches` et ce que le `postinstall` exige. `bun install --frozen-lockfile --filter <app>`.
- Le build du CMS reçoit `DATABASE_URL`, `PAYLOAD_SECRET`, `SITE_SECRET` et e-mail **factices** ; il régénère types et import map.
- `trace-runtime.ts` (`@vercel/nft`) produit `standalone/node_modules`. Les binaires Sharp glibc sont retirés sur Alpine ; vérifier `require("sharp")` dans l'étape `deps`.
- `tools-deps` retire dans une étape séparée ce que le CLI Payload ne charge pas, pour que ces fichiers ne restent pas dans une couche.
- `.dockerignore` exclut `.env`, sorties, `node_modules`, stockage et rapports.
- `apps/cms/server.mjs` : fichiers précompressés, cache d'un an sur `assets/`, `bun: { idleTimeout: 120 }`. Par défaut, Bun coupe après une dizaine de secondes un client muet, comme un envoi de média figé.
- Pas de retest utile : `bun --smol` (peu de mémoire gagnée, pages en cache deux fois plus lentes). Le gain d'image vient surtout de la trace et de l'élagage ; mesurer la mémoire après activité, pas seulement au démarrage.
## Variables
| Variable | Quand / usage |
| --- | --- |
| `PUBLIC_PAYLOAD_URL`, `PUBLIC_SITE_URL` | build Web et runtime CMS/Web ; origines HTTPS complètes |
| `PUBLIC_ALLOW_INDEXING` | build Web et runtime ; défaut `false` |
| `PAYLOAD_INTERNAL_URL` | runtime Web, alias privé fixé par Compose |
| `WEB_INTERNAL_URL` | runtime CMS, alias privé fixé par Compose |
| `SITE_SECRET` | runtime CMS/Web, `${VAR:?}` |
| `DATABASE_URL`, `PAYLOAD_SECRET` | runtime CMS et `migrate` seulement, `${VAR:?}` |
| `EMAIL_HOST`, `EMAIL_USER`, `EMAIL_PASS` | runtime CMS ; `EMAIL_PASS` facultatif |
Compose référence explicitement chaque variable : une option absente du fichier n'arrive pas au conteneur, même présente dans le `.env`. Pour une option facultative, ajouter un override. Pas d'`env_file` d'application.
## Topologie
Pas de `ports` dans le fichier principal, pas de `container_name`. Le nom du projet et du réseau privé sont fixés ; une seconde installation sur le même serveur adapte noms et URL internes par override.
Sur Coolify : `compose.coolify.yaml` rattache les services au réseau externe, domaines affectés aux ports internes 3000 et 4321. PostgreSQL d'une autre ressource : rattacher CMS et `migrate` à son réseau. `localhost` désigne le conteneur.
Un seul service `migrate` ponctuel sous profil `tools` : il ne reste pas actif, et ni les réplicas ni le démarrage ne migrent.
## Santé
CMS : `wget` sur `/admin` et `/api/health`, 30 s, trois échecs, `start_period` de 90 s, `start_interval` de 2 s (Docker Engine 25+). `/api/health` échoue aussi après trois minutes sans boucle de fond réussie. Les tâches de fond démarrent à l'initialisation de Payload, donc à la première requête, souvent la sonde.
Web : `wget` sur `/api/health`, qui prouve le chemin vers le CMS. Un état `unhealthy` seul ne redémarre pas un conteneur : configurer une alerte.
Ces sondes prouvent des chemins locaux, pas le DNS ni le TLS du proxy extérieur. Tester ceux-ci séparément.
## Persistance et schéma
Volume uploads sur `/app/apps/cms/storage`, côté CMS et `migrate`. Si des médias sont versionnés pour le serveur, un script au démarrage complète le volume sans écraser, sauf empreinte déclarée comme remplacée.
Sauvegarde : `pg_dump --format=custom` et volume uploads, en cohérence, gérés sur l'hébergement. Tester la restauration dans une base distincte.
Première installation : `docker compose run --rm --no-deps -e FIRST_ADMIN_EMAIL -e FIRST_ADMIN_PASSWORD migrate bun run admin:create`, depuis l'environnement du terminal. La création publique du premier compte est refusée en production.
## Adresse réelle des visiteurs
`TRUST_PROXY=true` et liste vide : seuls les CIDR privés réellement attachés au conteneur sont fiables. Publier le port du site impose de restreindre `TRUSTED_PROXY_IPS`. Vérifier derrière le proxy que deux IP clientes ont chacune leur quota.
## Validation
`audit-compose.mjs` : signaux statiques. `docker compose config --quiet` : syntaxe et résolution, avec valeurs de test. Un build complet sous émulation amd64 sur Mac peut échouer sans rapport avec le projet : valider x64 sur le runner seul ou sur le serveur réel, et surveiller le premier déploiement.
`docker compose up` utilise les images déjà taguées si rien ne force le build : reconstruire avant une recette, sinon elle teste d'anciennes images.
references/diagnostics.md
# Diagnostic par preuve
Lire le symptôme et le code de la version déployée avant de changer une configuration. Les commandes sont des sondes à adapter ; elles n'autorisent ni déploiement ni mutation.
## Build
- Archive npm indisponible : vérifier registre, disque et cache avant de toucher au lockfile.
- Sharp introuvable : vérifier `require("sharp")` dans l'étape `deps` et les paquets musl verrouillés. Pas de `bun add` au build.
- Erreur de base ou de secret au build : Payload charge sa configuration ; les `ENV` factices doivent couvrir chaque garde, 32 caractères pour les secrets.
- `rolldown`/`wasm-simd` sous émulation amd64 : limite de l'émulation, pas du projet.
## Démarrage du CMS
- `Cannot find module` au démarrage de l'image : module chargé par un nom calculé, absent du tracé : l'ajouter aux entrées de `trace-runtime.ts`. Ne pas retirer un paquet du runner sur la seule foi de son nom.
- `Invalid URL` et conteneur `unhealthy` : une origine `PUBLIC_*` sans `https://`.
- `La variable … est obligatoire` : garde volontaire. Vérifier que Compose la transmet.
- « Schéma PostgreSQL incompatible » ou lectures en erreur : migrations non appliquées. Relire et appliquer sur la cible désignée, jamais de push.
Lire les logs runtime, sans dump d'environnement. Une `AbortError` suivie d'un 500 lors d'une déconnexion client est un bruit connu sous Bun.
## Web vivant mais contenu absent
```bash
docker compose ps
docker compose logs --tail=100 web
docker compose exec web wget -qO- http://127.0.0.1:4321/api/health
```
Cette sonde suit le chemin Web → CMS → PostgreSQL sans afficher d'URL ni de secret. Une 503 sur une page froide est le chemin explicite d'erreur ; une 502 vient souvent du proxy. Une page chaude peut rester servie pendant une panne du CMS.
## DNS et origines
Vérifier depuis le Web l'hôte réellement injecté ; un alias générique peut entrer en collision sur un réseau commun. Un nom qui ne résout pas et un port fermé sont des causes différentes. Si une URL publique change, rebuild du Web. Si d'anciennes variables sont injectées, chercher leur consommateur.
## Cache
```bash
curl -sS -D - -o /dev/null https://example.com/page
```
Observer l'en-tête de cache Astro sur une route cacheable : MISS, HIT, puis MISS après invalidation ciblée. La preview ne doit jamais être stockée.
Publication visible dans le CMS mais page obsolète : même `SITE_SECRET` des deux côtés (rapporter seulement l'égalité), `WEB_INTERNAL_URL`, journaux d'invalidation, puis tag consommé et émis. La tâche durable reprend après coup ; le filet de cinq minutes s'applique sinon. Plusieurs processus à mémoire indépendante expliquent une fraîcheur intermittente. Un redémarrage qui vide la mémoire ne prouve pas que le graphe est réparé.
## Volumes et médias
`/media/*` en 500 alors que la base référence les fichiers : volume vide. Un média envoyé depuis un CMS local sur une base partagée n'arrive pas seul sur le serveur. Vérifier montages et droits de l'utilisateur `bun`. Ne pas réinitialiser la base ni supprimer un volume pour diagnostiquer un 404.
## Arrêt lent
Un conteneur qui attend son délai avant d'être tué : `init: true` manquant.
scripts/audit-compose.mjs
#!/usr/bin/env bun
// Read-only structural checks for the Compose shape used by Celuga projects.
// Run docker compose config --quiet separately: this scanner is not a general YAML parser.
import fs from 'node:fs'
import path from 'node:path'
const input = process.argv[2] ?? 'compose.yaml'
const file = input === '-' ? '<stdin>' : path.resolve(input)
let source
try { source = fs.readFileSync(input === '-' ? 0 : file, 'utf8') }
catch { console.error('[FAIL] Compose missing or unreadable'); process.exit(1) }
const failures = [], warnings = [], passes = []
const check = (condition, message, level = failures) => {
if (condition) passes.push(message)
else level.push(`Missing or invalid: ${message}`)
}
// Ignore full-line comments so commented examples cannot satisfy a required service/setting.
const lines = source.split(/\r?\n/).filter((line) => !/^\s*#/.test(line))
const section = (key, indent, from = lines) => {
const start = from.findIndex((line) => line === `${' '.repeat(indent)}${key}:`)
if (start < 0) return ''
let end = start + 1
while (end < from.length && (!from[end].trim() || from[end].search(/\S/) > indent)) end++
return from.slice(start + 1, end).join('\n')
}
const services = section('services', 0)
const serviceLines = services.split('\n')
const cms = section('cms', 2, serviceLines)
const web = section('web', 2, serviceLines)
const migrate = section('migrate', 2, serviceLines)
const networks = section('networks', 0)
check(Boolean(services), 'services block present')
check(Boolean(cms), 'cms service present')
check(Boolean(web), 'web service present')
check(Boolean(migrate) && /profiles:/.test(migrate), 'one-off migrate service under a profile', warnings)
const valueOf = (block, name) => block.match(new RegExp(`^\\s+${name}:[ \\t]*(.+)$`, 'm'))?.[1]?.trim() ?? ''
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, '$2')
const internal = unquote(valueOf(web, 'PAYLOAD_INTERNAL_URL'))
check(Boolean(internal), 'web declares PAYLOAD_INTERNAL_URL')
if (internal.startsWith('${')) warnings.push('PAYLOAD_INTERNAL_URL is interpolated; resolved host/network must be checked on the platform')
else if (/^https?:\/\/[^/:]*\$\{/.test(internal)) {
// Host built from the project name, e.g. http://${COMPOSE_PROJECT_NAME}-cms:3000
check(cms.includes(internal.replace(/^https?:\/\//, '').replace(/:\d+.*$/, '')), 'CMS alias declared (inspect network scope manually)')
} else if (internal) {
try {
const url = new URL(internal)
check(!url.username && !url.password, 'internal URL contains no credentials')
check(['http:', 'https:'].includes(url.protocol), 'internal URL uses HTTP(S)')
if (url.protocol === 'https:') warnings.push('Internal fetch uses public HTTPS path; inspect proxy/TLS dependency')
else {
check(!['cms', 'payload', 'api', 'backend', 'localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname), 'internal CMS host is project-specific')
check(!/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname), 'internal CMS host is not a fixed container IP')
check(cms.includes(url.hostname), 'CMS alias declared (inspect network scope manually)')
}
} catch { failures.push('PAYLOAD_INTERNAL_URL is not a valid URL (value redacted)') }
}
check(Boolean(valueOf(cms, 'WEB_INTERNAL_URL')), 'cms declares WEB_INTERNAL_URL', warnings)
for (const [name, block] of [['cms', cms], ['web', web]]) {
const health = section('healthcheck', 4, block.split('\n'))
check(Boolean(health), `${name} explicit healthcheck present`)
check(/\/api\/health/.test(health), `${name} healthcheck reaches /api/health`, warnings)
if (health && !/start_period:/.test(health)) warnings.push(`${name} healthcheck has no start_period`)
}
check(/depends_on:\s*\n\s+cms:\s*(?:\{\s*condition:\s*service_healthy|\n\s+condition:\s*service_healthy)/.test(web), 'web waits for cms service_healthy')
check(/^ {2}\w[\w-]*:/m.test(networks), 'explicit private network declared', warnings)
if (/container_name:/.test(services)) failures.push('Fixed container_name prevents independent parallel stacks')
const secretIn = (block, service, name) => {
const value = unquote(valueOf(block, name))
check(Boolean(value), `${service} declares ${name}`)
if (value && !value.startsWith('${')) failures.push(`${service} ${name} contains a literal value (redacted)`)
else if (value && !value.includes(':?')) warnings.push(`${service} ${name} not required at Compose interpolation; verify runtime validation`)
}
for (const name of ['DATABASE_URL', 'PAYLOAD_SECRET', 'SITE_SECRET']) secretIn(cms, 'cms', name)
secretIn(web, 'web', 'SITE_SECRET')
for (const name of ['DATABASE_URL', 'PAYLOAD_SECRET']) if (new RegExp(`\\b${name}\\b`).test(web)) failures.push(`web must not receive ${name}`)
if (/SITE_SECRET:[^\n]*PAYLOAD_SECRET/.test(services)) failures.push('Site secret falls back to the Payload secret')
if (/CACHE_SECRET|PREVIEW_SECRET|FORM_SECRET|CACHE_INVALIDATION_SECRET|PAYLOAD_PUBLIC_SERVER_URL|PAYLOAD_PUBLIC_SITE_URL/.test(services)) warnings.push('Legacy variable names found; check what the code actually reads')
for (const name of ['PUBLIC_PAYLOAD_URL', 'PUBLIC_SITE_URL']) check(Boolean(valueOf(cms, name)) && Boolean(valueOf(web, name)), `${name} declared for CMS and Web`)
check(Boolean(valueOf(section('args', 6, section('build', 4, web.split('\n')).split('\n')), 'PUBLIC_SITE_URL')), 'web build args carry public origins')
if (/PAYLOAD_ENABLE_PUSH:\s*['"]?true/.test(services)) failures.push('Schema push enabled')
const indexing = valueOf(web, 'PUBLIC_ALLOW_INDEXING')
if (!indexing) warnings.push('No explicit indexing setting')
else if (!indexing.includes('${')) warnings.push('Indexing value is fixed; preserve intentional environment policy and rebuild for any change')
check(/uploads|storage|media/.test(section('volumes', 4, cms.split('\n'))) || /volumes:\s*\[/.test(cms), 'CMS persistent uploads volume declared')
if (/volumes:/.test(web)) warnings.push('Web mounts a volume; uploads should stay on the CMS, served through the proxy')
if (/^\s{4}ports:/m.test(services)) warnings.push('Host ports published; verify intended proxy/network exposure')
for (const [name, block] of [['cms', cms], ['web', web]]) {
check(/^\s+init:\s*true/m.test(block), `${name} init: true (SIGTERM reaches the server)`, warnings)
check(/cap_drop:/.test(block), `${name} drops Linux capabilities`, warnings)
check(/no-new-privileges/.test(block), `${name} forbids privilege escalation`, warnings)
}
if (/REPLACE_[A-Z0-9_]+/.test(lines.join('\n'))) failures.push('Unresolved template marker')
for (const [level, messages] of [['PASS', passes], ['WARN', warnings], ['FAIL', failures]]) for (const message of messages) console.log(`[${level}] ${message}`)
console.log(`${passes.length} PASS, ${warnings.length} WARN, ${failures.length} FAIL — ${file}`)
console.log('Static only: no variable resolution, Docker build, live DNS, DB, health or cache transitions were tested.')
process.exitCode = failures.length ? 1 : 0
scripts/test-audit.mjs
#!/usr/bin/env bun
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const script = fileURLToPath(new URL('./audit-compose.mjs', import.meta.url))
const template = fs.readFileSync(new URL('../assets/docker-compose.coolify.yaml', import.meta.url), 'utf8')
const run = (input) => spawnSync(process.execPath, [script, '-'], { input, encoding: 'utf8' })
const baseline = run(template)
assert.equal(baseline.status, 0, `template baseline\n${baseline.stdout}`)
assert.ok(!baseline.stdout.includes('[WARN]'), `template should not warn\n${baseline.stdout}`)
assert.equal(run('').status, 1, 'empty input')
const expectFailure = (source, message) => {
assert.notEqual(source, template, `fixture did not change: ${message}`)
const result = run(source)
assert.equal(result.status, 1, message)
assert.ok(result.stdout.includes(message), `${message}\n${result.stdout}`)
}
const secretRef = '${SITE_SECRET:?Secret des échanges CMS-Web requis}'
expectFailure(template.replaceAll('my-project-cms', 'cms'), 'internal CMS host is project-specific')
expectFailure(template.replace(/ SITE_SECRET:.*\n/, ''), 'cms declares SITE_SECRET')
expectFailure(template.replaceAll(secretRef, 'sensitive-value'), 'literal value (redacted)')
assert.ok(!run(template.replaceAll(secretRef, 'sensitive-value')).stdout.includes('sensitive-value'), 'literal secrets are never printed')
expectFailure(template.replace(' PAYLOAD_INTERNAL_URL:', ' DATABASE_URL: ${DATABASE_URL:?x}\n PAYLOAD_INTERNAL_URL:'), 'web must not receive DATABASE_URL')
expectFailure(template.replace(' web:', ' # web:'), 'web service present')
expectFailure(template.replaceAll('healthcheck:', 'disabled-check:'), 'cms explicit healthcheck present')
expectFailure(template.replace('cms: { condition: service_healthy }', 'cms: { condition: service_started }'), 'web waits for cms service_healthy')
console.log('PASS: template, empty/missing service, generic host, absent/literal secret redaction, secrets kept off the web, healthchecks and startup order')