---
title: "Symfony en prod sur un mono-VPS : FrankenPHP + K3s, staging + TLS en une demi-journée"
excerpt: "Guide pas-à-pas pour mettre une application Symfony en production sur un mono-VPS : deux environnements (staging + prod) containerisés avec FrankenPHP, orchestrés par K3s, déployés automatiquement via GitHub Actions vers GHCR, avec TLS Let's Encrypt. Tous les snippets sont fournis et prêts à copier."
publishDate: 2026-04-18T00:00:00.000Z
tags: ["devops", "kubernetes", "k3s", "docker", "frankenphp", "symfony", "php", "cicd", "github-actions", "lets-encrypt", "production", "vps", "tls", "ghcr", "traefik"]
canonical: "https://yoandev.co/symfony-frankenphp-k3s-production"
---

Comment met-on une app Symfony en production, avec staging séparé, HTTPS automatique et déploiement à la commande, sans passer par un PaaS ni assembler dix tutos partiels ? C'est ce que détaille ce guide, étape par étape, avec tous les snippets fournis.

## TL;DR

> 📌 **Ce que vous allez obtenir à la fin** :
> - Un Dockerfile multi-stage FrankenPHP (PHP 8.4, worker mode, non-root)
> - Un workflow GitHub Actions qui pousse vers GHCR et déclenche un `kubectl rollout restart` via SSH
> - Des manifestes K3s dupliqués pour `staging` et `prod`
> - cert-manager + Let's Encrypt HTTP-01 via Traefik
> - Deux enregistrements DNS (A) à créer chez votre registrar
> - Une DB SQLite persistée sur un `PersistentVolumeClaim` `local-path`
>
> **Pattern de release** : `push` sur `main` → staging. `git tag vX.Y.Z` → prod. Le tout sur un seul VPS.
>
> **Coût mensuel** : ~4-6 € (le VPS). GHCR, Let's Encrypt et GitHub Actions (sous 2 000 min/mois en repo privé, illimité en public) sont gratuits.

<small>🧪 Article pédagogique et exploratoire. La stack tient debout mais reste minimaliste et **devra être durcie (secrets externalisés, backup testé, monitoring, sécurité du VPS…) pour être sérieusement prod-ready**. Les gaps sont listés [en bas de page](#ce-quil-reste-à-ajouter-pour-aller-vraiment-en-prod) (et ça reste encore incomplet).</small>

---

## Ce que vous allez construire

Un cycle de déploiement complet, de `git push` à l'application servie en HTTPS, pour une application Symfony. Deux environnements distincts (staging pour valider, prod pour servir), images versionnées dans un registry, TLS automatique, déploiement déclenché par `git push`.

L'application prise en exemple : **[Symfony Demo](https://github.com/symfony/demo)**, une app de blog complète avec authentification, CRUD, base de données SQLite et tests. Assez riche pour représenter une vraie app.

La stack :

- **Docker + FrankenPHP** pour la containerisation
- **K3s** pour l'orchestration (mono-node, sur un VPS)
- **GitHub Actions + GHCR** pour la CI/CD
- **Traefik + cert-manager** pour l'ingress et le TLS Let's Encrypt
- **DNS** : vous créez les enregistrements A chez votre registrar (via leur UI, leur API, ExternalDNS, peu importe)
- **SQLite** persistée sur un `PersistentVolumeClaim`

L'article donne **tous les bouts de config**, prêts à copier. Vous remplacez les placeholders (IP, domaine, organisation GitHub) et vous avez une stack opérationnelle.

### Pourquoi K3s plutôt que Docker Compose, un K8s managé, ou Kubernetes « classique » ?

C'est une question légitime, alors posons-la tout de suite.

> 🎯 **Docker Compose** est parfait pour du dev ou un prod très simple. Dès qu'on veut du vrai *rolling update*, des probes de readiness/liveness, des PVC séparés par env, un Ingress avec TLS auto, on commence à réimplémenter à la main des morceaux de Kubernetes. Autant prendre l'outil qui fait ça nativement.
>
> ☁️ **Un K8s managé** (GKE, EKS, AKS, OVH Managed K8s…) est conçu pour du multi-node, du multi-équipe, du haut disponibilité. Facturé en conséquence : minimum 30, 100 €/mois pour un cluster vide, avant même d'y déployer quoi que ce soit. Surdimensionné pour une app de blog ou un SaaS naissant.
>
> 🏗️ **Kubernetes « vanilla »** (kubeadm, kops…) est lourd à installer, à maintenir, à patcher. Plusieurs binaires, de l'etcd séparé, des composants à tenir à jour. Ingénierie justifiée sur 10 nodes et 5 équipes, pas sur 1 VPS et 1 dev.
>
> ⚡ **K3s** occupe exactement l'espace entre les deux : un **seul binaire de ~60 Mo**, `curl | sh` et c'est installé, ~500 Mo de RAM à vide, Traefik/ingress/storage inclus. Et surtout : **API 100 % compatible avec Kubernetes**. Si le projet grossit, vous ajoutez des nodes, vous migrez vers un vrai cluster managé, vos manifestes marchent tels quels. Zéro lock-in.

En clair : vous écrivez les mêmes YAML qu'un cluster de prod à 10 000 €/mois, mais vous les faites tourner sur un VPS à 5 €. Quand viendra le jour où ça ne tient plus, la migration est mécanique.

---

## Architecture cible

```mermaid
%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 70, 'padding': 18}}}%%
flowchart TB
    G["GitHub<br/>repo + Actions"]
    GH["GHCR<br/>registry d'images"]
    V["VPS · K3s single-node<br/>Traefik · servicelb · local-path"]
    S["<b>Namespace staging</b><br/>staging.mon-domaine.tld<br/>Deployment · PVC 1 Gi"]
    P["<b>Namespace prod</b><br/>prod.mon-domaine.tld<br/>Deployment · PVC 1 Gi"]
    D["DNS registrar<br/>records A"]

    G ==>|"1. push image<br/>:staging-* ou :prod-*"| GH
    G ==>|"2. ssh + kubectl<br/>rollout restart"| V
    V ==>|"3. pull image"| GH
    V --> S
    V --> P
    D -.->|"résolution<br/>publique"| V
```

Deux namespaces K3s sur le même VPS. Chacun son image, son PVC, son certificat. Isolation logique suffisante pour un projet de cette taille.

> 🧭 **Quelques notions Kubernetes à connaître avant d'avancer** :
> - **Kubernetes (K8s)** : orchestrateur de containers. Il décide où et comment faire tourner vos conteneurs, les redémarre s'ils crashent, les connecte entre eux.
> - **K3s** : une distribution légère de Kubernetes, packagée en un seul binaire (~60 Mo), parfaite pour un mono-VPS. API, comportement et manifestes **100 % compatibles** avec K8s « classique ».
> - **`kubectl`** : la CLI officielle pour parler à l'API Kubernetes (déployer, consulter, supprimer des ressources).
> - **Pod** : la plus petite unité déployable, un ou plusieurs containers qui partagent le même réseau et les mêmes volumes. Dans cet article, chaque pod contient un seul container FrankenPHP.
> - **Namespace** : une "boîte" logique qui isole un groupe de ressources. On en crée un pour `staging` et un pour `prod` : les deux cohabitent sur le même cluster mais ne se voient pas.
>
> Les autres ressources (`Deployment`, `Service`, `Ingress`, `ConfigMap`, `Secret`, `PersistentVolumeClaim`, etc.) sont définies au fur et à mesure qu'on les utilise.

---

## Étape 1 · Containerisation avec FrankenPHP

### Pourquoi FrankenPHP

[FrankenPHP](https://frankenphp.dev/) est un serveur applicatif PHP moderne, bâti sur Caddy, qui sait tourner en **worker mode** (le bootstrap du kernel Symfony est partagé entre requêtes, on évite de recharger le framework à chaque hit). Performance comparable à RoadRunner, config beaucoup plus simple qu'une stack PHP-FPM + Nginx classique.

Bonus : l'image officielle `dunglas/frankenphp` embarque Caddy avec TLS automatique (utile pour du dev local). En K3s, on délègue le TLS à Traefik/cert-manager, l'image écoute juste sur `:80`.

### Le Dockerfile

Trois stages : résolution des deps, build des assets, runtime minimal.

```dockerfile
# syntax=docker/dockerfile:1.7

ARG FRANKENPHP_VERSION=1-php8.4-bookworm
ARG COMPOSER_VERSION=2

# -----------------------------------------------------------------------
# Stage 1. composer_deps : résolution des dépendances PHP (cache layer)
# -----------------------------------------------------------------------
FROM composer/composer:${COMPOSER_VERSION}-bin AS composer_bin

FROM dunglas/frankenphp:${FRANKENPHP_VERSION} AS composer_deps

COPY --from=composer_bin /composer /usr/local/bin/composer

RUN install-php-extensions \
        pdo_sqlite \
        intl \
        opcache \
        apcu \
        zip

ENV COMPOSER_ALLOW_SUPERUSER=1 \
    COMPOSER_NO_INTERACTION=1

WORKDIR /app

COPY composer.json composer.lock symfony.lock ./

RUN composer install \
        --no-dev \
        --no-scripts \
        --no-autoloader \
        --prefer-dist \
        --no-progress

# -----------------------------------------------------------------------
# Stage 2. app_builder : build des assets + cache Symfony prod
# -----------------------------------------------------------------------
FROM dunglas/frankenphp:${FRANKENPHP_VERSION} AS app_builder

COPY --from=composer_bin /composer /usr/local/bin/composer

RUN install-php-extensions \
        pdo_sqlite \
        intl \
        opcache \
        apcu \
        zip

ENV COMPOSER_ALLOW_SUPERUSER=1 \
    COMPOSER_NO_INTERACTION=1 \
    APP_ENV=prod \
    APP_DEBUG=0

WORKDIR /app

COPY --from=composer_deps /app/vendor ./vendor
COPY . .

# Déplace la DB seed hors de /app/data (le volume runtime masquera ce chemin)
RUN mkdir -p _initial_data && \
    if [ -f data/database.sqlite ] && [ ! -f _initial_data/database.sqlite ]; then \
        mv data/database.sqlite _initial_data/database.sqlite; \
    fi && \
    rm -rf data && mkdir -p data

RUN composer dump-autoload --classmap-authoritative --no-dev && \
    composer run-script --no-dev post-install-cmd || true

# Rebuild explicite des assets
RUN php bin/console cache:clear --no-debug && \
    php bin/console cache:warmup --no-debug && \
    php bin/console assets:install public --symlink --relative && \
    php bin/console importmap:install && \
    php bin/console sass:build && \
    php bin/console asset-map:compile

# -----------------------------------------------------------------------
# Stage 3. runtime : image finale
# -----------------------------------------------------------------------
FROM dunglas/frankenphp:${FRANKENPHP_VERSION} AS runtime

RUN install-php-extensions \
        pdo_sqlite \
        intl \
        opcache \
        apcu \
        zip

ENV APP_ENV=prod \
    APP_DEBUG=0 \
    SERVER_NAME=":80" \
    FRANKENPHP_CONFIG="worker ./public/index.php"

COPY docker/php/app.ini     /usr/local/etc/php/conf.d/app.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/Caddyfile       /etc/caddy/Caddyfile

WORKDIR /app

COPY --from=app_builder --chown=www-data:www-data /app /app
COPY --chmod=0755 docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

RUN chown -R www-data:www-data /app/var /app/data /app/public

VOLUME ["/app/data"]
EXPOSE 80

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -fsS http://127.0.0.1/_health || exit 1

USER www-data

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
```

### À propos de `asset-map:compile`

Notez la présence explicite de cette ligne dans le builder :

```dockerfile
php bin/console asset-map:compile
```

Elle est **obligatoire pour AssetMapper en prod**. [AssetMapper](https://symfony.com/doc/current/frontend/asset_mapper.html) sert les assets dynamiquement en dev (via un contrôleur qui résout les noms hashés à la volée), mais en prod il faut **pré-compiler** les fichiers sur disque dans `public/assets/`. Ce n'est pas dans le `post-install-cmd` par défaut de `composer`, il faut l'ajouter explicitement au build de l'image.

### Caddyfile : worker mode, TLS délégué à l'ingress

`docker/Caddyfile` :

```caddy
{
    auto_https off
    frankenphp {
        worker /app/public/index.php 4
    }
    servers {
        trusted_proxies static private_ranges
    }
}

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

    php_server {
        resolve_root_symlink
    }

    log {
        output stdout
        format console
    }
}
```

Points clés :

- `auto_https off` : c'est Traefik qui gère TLS côté K3s, FrankenPHP écoute en HTTP pur
- `worker /app/public/index.php 4` : 4 workers Symfony, le kernel est bootstrapé une fois et réutilisé
- `trusted_proxies static private_ranges` : indispensable pour que Symfony voie les vraies IPs clientes derrière l'ingress

### Entrypoint : seed SQLite au premier boot

`docker/docker-entrypoint.sh` :

```sh
#!/bin/sh
set -eu

DATA_DIR="/app/data"
SEED_FILE="/app/_initial_data/database.sqlite"
DB_FILE="${DATA_DIR}/database.sqlite"

mkdir -p "${DATA_DIR}"

if [ ! -f "${DB_FILE}" ] && [ -f "${SEED_FILE}" ]; then
    echo "[entrypoint] Seeding database from ${SEED_FILE}"
    cp "${SEED_FILE}" "${DB_FILE}"
fi

exec "$@"
```

Le pattern : l'image embarque une copie read-only de la DB de démo dans `/app/_initial_data/database.sqlite`. Au premier démarrage du pod, si le volume monté sur `/app/data` est vide, on copie la seed dedans. Les démarrages suivants ne touchent plus à rien, le PVC conserve la DB.

> 📦 **PVC = PersistentVolumeClaim**. C'est la façon dont Kubernetes attache un volume de stockage persistant à un pod. Contrairement au système de fichiers du container (qui est recréé à chaque redémarrage), le contenu d'un PVC survit aux rolling updates, aux crashs de pod et aux redémarrages du node. Dans notre cas, il vit sur le disque du VPS (storageClass `local-path`), et c'est lui qui permet à SQLite de garder ses données d'un déploiement à l'autre.

### Config PHP

`docker/php/opcache.ini` :

```ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.jit=tracing
opcache.jit_buffer_size=100M
```

`opcache.validate_timestamps=0` = pas de revalidation disque. Cohérent en prod puisque le code ne change pas dans un pod.

`docker/php/app.ini` :

```ini
memory_limit=256M
date.timezone=Europe/Paris
expose_php=Off
session.cookie_secure=1
session.cookie_httponly=1
session.cookie_samesite=Lax
```

### `.dockerignore`

Sans ça, le build copie des tonnes de trucs inutiles (dont potentiellement des secrets).

```
.git/
.github/
.idea/
.vscode/
.phpunit.cache/
.php-cs-fixer.cache
.php-version

var/
vendor/
node_modules/
public/assets/
public/bundles/
assets/vendor/

tests/
phpunit.xml
phpstan.neon

*.md
LICENSE

.env.local
.env.*.local
.env.deploy
.env.test.local

data/*
!data/.gitkeep

docker-compose*.yml
k8s/
scripts/
```

---

## Étape 2 · Healthcheck applicatif

K8s a besoin d'un endpoint de readiness/liveness. On crée un mini contrôleur Symfony :

`src/Controller/HealthController.php` :

```php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;

final class HealthController extends AbstractController
{
    public function __invoke(): JsonResponse
    {
        return new JsonResponse(['status' => 'ok']);
    }
}
```

### Déclarer la route hors du préfixe `/{_locale}`

Symfony Demo préfixe tous les contrôleurs par `/{_locale}` via `config/routes.yaml`. Si on laisse un `#[Route('/_health')]` sur le contrôleur, la route devient `/{_locale}/_health`, et K8s va probe `/_health` (sans locale) qui renverra 404.

On déclare donc la route directement dans `config/routes.yaml`, hors du préfixe :

```yaml
homepage:
    path: /{_locale}
    controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController::templateAction
    defaults:
        template: default/homepage.html.twig
        _locale: '%app.locale%'

controllers:
    resource: routing.controllers
    prefix: /{_locale}
    defaults:
        _locale: '%app.locale%'

# Infra health endpoint, no locale prefix (consumed by K8s probes / Docker healthcheck).
app_health:
    path: /_health
    controller: App\Controller\HealthController
```

Et on retire l'attribut `#[Route]` du contrôleur pour éviter le doublon.

---

## Étape 3 · K3s : bootstrap VPS + install

### Préambule sécurité : votre compte VPS

La plupart des fournisseurs de VPS livrent votre serveur avec un accès **`root` activé et mot de passe envoyé par e-mail**. C'est une convention commode mais **vous ne devriez jamais utiliser ce compte directement**.

Avant de poser quoi que ce soit sur le serveur, créez un compte personnel avec sudo, posez-y votre clé SSH, puis désactivez la connexion root et l'auth par mot de passe :

```bash
# Depuis votre machine, connecté en root (première et seule fois)
ssh root@XX.XX.XX.XX

# Sur le VPS
adduser mon-admin                                       # mot de passe solide
usermod -aG sudo mon-admin                              # ajout au groupe sudo
install -o mon-admin -g mon-admin -m 0700 -d /home/mon-admin/.ssh
# Copier votre clé publique (~/.ssh/id_ed25519.pub sur votre machine) dans :
#   /home/mon-admin/.ssh/authorized_keys  (propriétaire mon-admin, mode 0600)

# Durcir sshd : pas de root, pas de mot de passe
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh
```

Ouvrez une **nouvelle session** et vérifiez que vous pouvez toujours vous connecter avec `mon-admin` avant de fermer la session root, si quelque chose cloche, vous aurez besoin de corriger depuis la console de votre provider.

### Install K3s

Un VPS avec Ubuntu 24.04 ou Debian récent fait l'affaire. Côté ressources, 2 vCPU et 2 Go de RAM suffisent pour faire tourner K3s + deux environnements Symfony. Depuis votre compte `mon-admin`, l'install tient en une ligne (le script a besoin de droits root, on le passe par `sudo`) :

```bash
curl -sfL https://get.k3s.io | \
  sudo INSTALL_K3S_EXEC="--write-kubeconfig-mode 644 --tls-san XX.XX.XX.XX" sh -
```

`--tls-san` ajoute l'IP publique au certificat de l'API K8s, pour pouvoir taper depuis votre machine avec `kubectl` sans erreur TLS.

Vérification :

```bash
kubectl get nodes
# NAME         STATUS   ROLES           AGE   VERSION
# srv-xxx      Ready    control-plane   34s   v1.34.x+k3s1
```

### Les composants K3s par défaut

K3s embarque d'office quatre composants prêts à l'emploi :

- **Traefik** : un *ingress controller*, c'est-à-dire le composant qui reçoit les requêtes HTTP/HTTPS sur le node et les route vers le bon service interne en fonction du hostname et du chemin. Exposé sur les ports 80 et 443 du VPS.
- **servicelb** : une mini-implémentation du type de Service `LoadBalancer`. Dans un vrai cluster cloud, un `Service` `LoadBalancer` déclenche la création d'un LB externe (AWS ELB, GCP LB…). Sur un mono-VPS, servicelb fait plus simple : il binde directement les ports sur le node.
- **local-path-provisioner** : le composant qui fabrique les volumes persistants (`PersistentVolume`) en utilisant tout bêtement un dossier sur le disque du node (`/var/lib/rancher/k3s/storage/`). C'est lui qui matérialise vos PVC.
- **CoreDNS** : le serveur DNS interne du cluster, qui permet aux pods de se résoudre entre eux par leur nom de `Service` (ex : `app.staging.svc.cluster.local`).

### Kubeconfig en local

Le **kubeconfig** est un fichier YAML qui contient les informations nécessaires à `kubectl` pour se connecter à un cluster : URL de l'API, certificat CA, credentials. K3s le génère automatiquement dans `/etc/rancher/k3s/k3s.yaml` sur le node. On le récupère en local pour pouvoir piloter le cluster depuis sa machine (plus confortable qu'un SSH à chaque commande), en remplaçant l'URL `127.0.0.1` par l'IP publique du VPS.

```bash
# Le fichier /etc/rancher/k3s/k3s.yaml a été rendu lisible par tous
# grâce à --write-kubeconfig-mode 644, donc mon-admin peut le scp sans sudo.
scp mon-admin@XX.XX.XX.XX:/etc/rancher/k3s/k3s.yaml ~/.kube/config-monvps
# sed -i '' sur macOS, sed -i sur Linux
sed -i '' "s|server: https://127.0.0.1:6443|server: https://XX.XX.XX.XX:6443|" ~/.kube/config-monvps
export KUBECONFIG=~/.kube/config-monvps
kubectl get nodes
```

Note : l'API kube (6443) est exposée publiquement avec ça. En production, passez par un tunnel SSH (`ssh -L 6443:localhost:6443 ...`) au lieu d'exposer le port 6443 en clair.

### Créer l'utilisateur `deploy` pour la CI

La CI a besoin de se connecter en SSH au VPS pour lancer deux commandes `kubectl`, pas plus. On lui crée un compte dédié, **sans sudo, sans mot de passe, sans shell privilégié**, juste un kubeconfig en lecture. Si la clé SSH de la CI fuite un jour, l'attaquant n'a accès qu'au cluster K8s (pas au système hôte) et on peut révoquer la clé sans toucher au compte admin.

Sur le VPS, depuis votre compte `mon-admin`, un seul bloc à exécuter :

```bash
# Créer l'utilisateur deploy, sans mot de passe, shell /bin/bash
sudo useradd -m -s /bin/bash deploy

# Lui donner un kubeconfig personnel pointant sur le cluster local
# (chemin standard ~/.kube/config → kubectl le trouve tout seul, pas besoin d'exporter KUBECONFIG)
sudo install -o deploy -g deploy -m 0600 -D /etc/rancher/k3s/k3s.yaml /home/deploy/.kube/config

# Préparer son dossier .ssh pour la clé publique (on l'injectera à l'étape suivante)
sudo install -o deploy -g deploy -m 0700 -d /home/deploy/.ssh
sudo install -o deploy -g deploy -m 0600 /dev/null /home/deploy/.ssh/authorized_keys
```

Ce compte n'a **aucun privilège sudo**, juste la capacité de lire son `~/.kube/config` et donc de parler à l'API K8s. C'est exactement ce qu'il faut pour `kubectl rollout restart`. La clé SSH dédiée à la CI sera générée plus tard, dans l'Étape 7 (bootstrap complet).

### cert-manager + ClusterIssuers

**cert-manager** est un opérateur Kubernetes (un programme qui tourne dans le cluster et gère des ressources custom) qui automatise toute la danse ACME de Let's Encrypt : il demande les certificats, répond aux challenges, les stocke dans des `Secret` K8s, et les renouvelle avant expiration. Zéro intervention manuelle une fois configuré.

```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
kubectl -n cert-manager rollout status deployment/cert-manager --timeout=120s
```

On déclare ensuite deux **`ClusterIssuer`**, une ressource custom créée par cert-manager qui représente une autorité de certification (Let's Encrypt staging pour tester sans rate-limit, Let's Encrypt prod pour les vrais certificats) :

`k8s/cert-manager/cluster-issuers.yaml` :

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: votre-email@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: votre-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
```

```bash
kubectl apply -f k8s/cert-manager/cluster-issuers.yaml
kubectl get clusterissuer
# NAME                  READY   AGE
# letsencrypt-prod      True    4s
# letsencrypt-staging   True    4s
```

**HTTP-01 > DNS-01 pour un mono-VPS** : le challenge HTTP-01 consiste, pour Let's Encrypt, à demander au serveur d'exposer un token sur `http://mon-domaine.tld/.well-known/acme-challenge/...` pour prouver qu'il contrôle bien le domaine. Traefik gère ça automatiquement quand un `Ingress` est annoté `cert-manager.io/cluster-issuer`. Pas de webhook registrar à installer. DNS-01, à l'inverse, exige de créer un record `TXT` dans la zone DNS, indispensable pour les certificats wildcard, pas nécessaire dans notre cas.

---

## Étape 4 · CI/CD GitHub Actions vers GHCR

Un seul workflow gère les deux environnements. Le trigger détermine la cible.

`.github/workflows/docker-build.yaml` :

```yaml
name: "Docker build & deploy"

on:
    push:
        branches:
            - main
        tags:
            - 'v*'
    workflow_dispatch:
        inputs:
            environment:
                description: "Target environment"
                required: true
                default: "staging"
                type: choice
                options:
                    - staging
                    - prod

permissions:
    contents: read
    packages: write

env:
    REGISTRY: ghcr.io
    IMAGE_NAME: ${{ github.repository }}

jobs:
    build:
        name: "Build & push image"
        runs-on: ubuntu-latest
        outputs:
            environment: ${{ steps.target.outputs.environment }}
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Determine target environment
              id: target
              run: |
                  if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
                      ENV="${{ github.event.inputs.environment }}"
                  elif [[ "${{ github.ref_type }}" == "tag" ]]; then
                      ENV="prod"
                  else
                      ENV="staging"
                  fi
                  echo "environment=${ENV}" >> "$GITHUB_OUTPUT"

            - name: Set up Docker Buildx
              uses: docker/setup-buildx-action@v3

            - name: Log in to GHCR
              uses: docker/login-action@v3
              with:
                  registry: ${{ env.REGISTRY }}
                  username: ${{ github.actor }}
                  password: ${{ secrets.GITHUB_TOKEN }}

            - name: Extract metadata
              id: meta
              uses: docker/metadata-action@v5
              with:
                  images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
                  tags: |
                      type=raw,value=${{ steps.target.outputs.environment }}-latest
                      type=raw,value=${{ steps.target.outputs.environment }}-${{ github.sha }}
                      type=ref,event=tag

            - name: Build & push image
              uses: docker/build-push-action@v6
              with:
                  context: .
                  file: ./Dockerfile
                  platforms: linux/amd64
                  push: true
                  tags: ${{ steps.meta.outputs.tags }}
                  labels: ${{ steps.meta.outputs.labels }}
                  cache-from: type=gha
                  cache-to: type=gha,mode=max

    deploy:
        name: "Deploy to K3s"
        runs-on: ubuntu-latest
        needs: build
        concurrency:
            group: deploy-${{ needs.build.outputs.environment }}
            cancel-in-progress: false
        steps:
            - name: Rollout restart on K3s via SSH
              uses: appleboy/ssh-action@v1.2.0
              with:
                  host: ${{ secrets.SSH_HOST }}
                  port: ${{ secrets.SSH_PORT }}
                  username: ${{ secrets.SSH_USER }}
                  key: ${{ secrets.SSH_PRIVATE_KEY }}
                  script: |
                      set -euo pipefail
                      # kubectl lit ~/.kube/config par défaut (installé à l'étape précédente)
                      NS="${{ needs.build.outputs.environment }}"
                      kubectl rollout restart deployment/app -n "${NS}"
                      kubectl rollout status deployment/app -n "${NS}" --timeout=3m
```

> 🔁 **`kubectl rollout restart`** : commande Kubernetes qui force le recyclage progressif des pods d'un Deployment, sans changer sa spec. Combiné avec `imagePullPolicy: Always` sur un tag mutable comme `:staging-latest`, c'est ce qui fait que la dernière image poussée au registry est effectivement déployée. **`kubectl rollout status`** bloque et suit l'avancement, avec un timeout, si le rollout casse (crash du pod, healthcheck KO…), la commande sort en erreur et fait échouer le job GitHub Actions.

**Secrets GitHub à configurer** :

| Secret | Valeur |
|---|---|
| `SSH_HOST` | IP ou hostname du VPS |
| `SSH_PORT` | `22` (ou ce que vous avez mis) |
| `SSH_USER` | `deploy` (le compte non-privilégié créé à l'Étape 3) |
| `SSH_PRIVATE_KEY` | clé ed25519 dédiée déploiement (génération dans l'Étape 7) |

**Le pattern de release** :

- `git push` sur `main` → image `:staging-latest` + `:staging-<sha>` → rollout auto en staging
- `git tag vX.Y.Z && git push --tags` → image `:prod-latest` + `:vX.Y.Z` → rollout auto en prod (exemple concret : `v1.0.0`)

Le `concurrency.group` par environnement empêche deux déploiements concurrents sur la même cible, sans bloquer un staging pendant qu'une prod se déploie.

---

## Étape 5 · Les manifestes K3s

Deux répertoires dupliqués : `k8s/staging/` et `k8s/prod/`. Pas de [Kustomize](https://kustomize.io/) pour l'instant (c'est un outil qui permet de factoriser des manifestes K8s avec un système de *base* + *overlays* par environnement) : la duplication est assumée (six fichiers, deux envs, diff minimal). On pourra toujours kustomizer plus tard si ça devient gênant.

### Namespace

Le `Namespace` est simplement une cloison logique dans le cluster. Toutes nos ressources (Deployment, Service, Secret…) vivent dedans, et on peut utiliser le même cluster pour plusieurs environnements sans collision.

`k8s/staging/namespace.yaml` :

```yaml
apiVersion: v1
kind: Namespace
metadata:
  name: staging
  labels:
    app.kubernetes.io/part-of: mon-app
    environment: staging
```

### ConfigMap

Une `ConfigMap` est une ressource clé-valeur pensée pour la **configuration non sensible** (variables d'env, URLs, feature flags…). Le Deployment peut l'injecter dans les containers en une seule ligne (`envFrom`). Changer une valeur ici et redéployer suffit à faire évoluer l'environnement d'un pod.

`k8s/staging/configmap.yaml` :

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: staging
data:
  APP_ENV: "prod"
  APP_DEBUG: "0"
  DATABASE_URL: "sqlite:////app/data/database.sqlite"
  DEFAULT_URI: "https://staging.mon-domaine.tld"
  MAILER_DSN: "null://null"
  TRUSTED_PROXIES: "127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
```

> ⚠️ Note sur `DATABASE_URL` : **quatre slashes** (`sqlite:////app/data/...`) pour un chemin absolu SQLite. Trois slashes = chemin relatif au `project_dir`, ce qui ne donnera pas le même résultat dans l'image.

### PersistentVolumeClaim (SQLite)

Le `PersistentVolumeClaim` (PVC) est la "demande" de volume faite par notre application. Kubernetes regarde la `storageClassName` pour savoir comment le provisionner : ici, `local-path` (fourni par K3s) va créer un `PersistentVolume` (PV) en allouant un dossier sur le disque du node. `ReadWriteOnce` signifie qu'un seul pod à la fois peut monter ce volume en écriture, parfait pour SQLite.

`k8s/staging/pvc.yaml` :

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
  namespace: staging
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi
```

`local-path` stocke le volume dans `/var/lib/rancher/k3s/storage/` sur le node. Pour un mono-VPS, c'est exactement ce qu'on veut. Pour du multi-node, il faudrait du Longhorn ou du NFS (autres *storage classes* qui exposent un volume accessible depuis n'importe quel node).

### Deployment

Le `Deployment` est **la ressource principale** de notre application. Il décrit :

- quel container faire tourner (image, variables, volumes, probes…),
- combien de copies (`replicas`),
- comment les mettre à jour (`strategy`).

Kubernetes se charge ensuite de maintenir cet état en permanence : si un pod crashe, il est recréé. Si on change l'image, un *rollout* remplace progressivement les pods par la nouvelle version.

`k8s/staging/deployment.yaml` :

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: staging
  labels:
    app.kubernetes.io/name: mon-app
    app.kubernetes.io/instance: staging
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: mon-app
      app.kubernetes.io/instance: staging
  template:
    metadata:
      labels:
        app.kubernetes.io/name: mon-app
        app.kubernetes.io/instance: staging
    spec:
      containers:
        - name: app
          image: ghcr.io/votre-org/votre-app:staging-latest
          imagePullPolicy: Always
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secret
          volumeMounts:
            - name: data
              mountPath: /app/data
          readinessProbe:
            httpGet:
              path: /_health
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /_health
              port: http
            initialDelaySeconds: 30
            periodSeconds: 20
          resources:
            requests:
              cpu: "200m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: app-data
```

**Deux choix importants, tous les deux liés à SQLite** :

- `replicas: 1` : on ne veut qu'un seul pod en vie. SQLite est single-writer, deux pods sur le même volume corrompent la DB en quelques minutes.
- `strategy.type: Recreate` : la stratégie par défaut de Kubernetes est `RollingUpdate` (elle démarre le nouveau pod AVANT de tuer l'ancien, pour du zéro-downtime). Avec SQLite ça ne va pas : deux pods tenteraient d'écrire en même temps. `Recreate` fait l'inverse, kill l'ancien, puis démarre le nouveau. Quelques secondes d'indisponibilité, mais DB intacte. Si vous migrez un jour vers Postgres ou MySQL, repassez en `RollingUpdate`.

**Les autres options à connaître** :

- **`imagePullPolicy: Always`** : force Kubernetes à re-vérifier l'image sur le registry à chaque démarrage de pod. Couplé au tag mutable `:staging-latest`, c'est ce qui fait que chaque `kubectl rollout restart` déploie bien la dernière version poussée. Sans ça, Kubernetes garderait l'image déjà téléchargée localement.
- **`readinessProbe`** : vérifie si le pod est **prêt à recevoir du trafic**. Tant que cette probe ne répond pas OK, le `Service` ne route aucune requête vers le pod. Utile pendant le boot (cache warmup, connexion DB…).
- **`livenessProbe`** : vérifie si le pod est **encore en vie**. Si elle échoue trop de fois, Kubernetes tue le pod et en relance un. Utile pour détecter un PHP qui aurait fait un deadlock interne.
- **`resources.requests` / `.limits`** : `requests` est la quantité de CPU/RAM garantie (utilisée par le scheduler pour placer le pod), `limits` est le plafond au-delà duquel le pod est throttlé (CPU) ou tué (OOM pour la RAM).

### Service + Ingress

Un `Service` est une **adresse réseau stable** pour un groupe de pods. Les pods changent d'IP à chaque redémarrage ; le `Service` leur donne un nom DNS interne (`app.staging.svc.cluster.local`) et une IP virtuelle qui reste stable. Les autres pods et l'Ingress s'adressent au Service, jamais aux pods directement. Le type `ClusterIP` (celui qu'on utilise ici) signifie que le Service n'est accessible que de l'intérieur du cluster, c'est exactement ce qu'il faut quand on passe par un Ingress qui gère l'exposition publique.

Un `Ingress` est **la porte d'entrée HTTP/HTTPS publique** du cluster. Il dit à Traefik (le contrôleur Ingress) : "quand une requête arrive sur `staging.mon-domaine.tld`, route-la vers le Service `app` du namespace `staging`". L'annotation `cert-manager.io/cluster-issuer` déclenche automatiquement la création d'un `Certificate` par cert-manager, qui remplira le Secret `staging-tls` avec une paire clé/cert valide.

`k8s/staging/service.yaml` :

```yaml
apiVersion: v1
kind: Service
metadata:
  name: app
  namespace: staging
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: mon-app
    app.kubernetes.io/instance: staging
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP
```

`k8s/staging/ingress.yaml` :

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  namespace: staging
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - staging.mon-domaine.tld
      secretName: staging-tls
  rules:
    - host: staging.mon-domaine.tld
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port:
                  name: http
```

> ⚠️ **Ne pas ajouter** l'annotation `traefik.ingress.kubernetes.io/router.entrypoints: websecure` sur cet Ingress. Elle force Traefik à n'écouter que sur 443 pour ce router, ce qui casse le port 80, y compris le chemin `/.well-known/acme-challenge/...` dont cert-manager a besoin pour valider le certificat. Par défaut, Traefik route sur `web` (80) ET `websecure` (443), ce qui est exactement ce qu'on veut. Pour une redirection HTTP → HTTPS systématique, utilisez un middleware Traefik dédié ; ne bloquez pas le port 80.

### Secret APP_SECRET (créé hors manifeste)

Un `Secret` est la ressource K8s pensée pour les **valeurs sensibles** (mots de passe, clés API, tokens…). Comme pour une `ConfigMap`, on peut l'injecter dans le container via `envFrom`, mais son contenu est stocké chiffré au repos côté cluster et le tooling (kubectl, dashboards…) masque les valeurs par défaut.

On ne commit jamais les valeurs. On crée le Secret une seule fois par namespace avec `kubectl` :

```bash
kubectl -n staging create secret generic app-secret \
  --from-literal=APP_SECRET=$(openssl rand -hex 32)
kubectl -n prod create secret generic app-secret \
  --from-literal=APP_SECRET=$(openssl rand -hex 32)
```

Le Deployment consomme ce secret via `envFrom`, `APP_SECRET` atterrit directement dans l'environnement du container, Symfony le lit tout seul.

---

## Étape 6 · Les enregistrements DNS

Avant que Let's Encrypt accepte de délivrer le certificat, les deux sous-domaines doivent déjà pointer vers l'IP publique du VPS. Deux enregistrements DNS à créer :

| Nom | Type | Valeur | TTL |
|---|---|---|---|
| `staging.mon-domaine.tld` | A | `XX.XX.XX.XX` | 3600 |
| `prod.mon-domaine.tld` | A | `XX.XX.XX.XX` | 3600 |

**Le comment est à votre main.** Interface web du registrar, CLI officielle (`flarectl` chez Cloudflare, `ovh-api-bash-client` chez OVH…), appel direct à l'API, ou un composant Kubernetes comme [`ExternalDNS`](https://kubernetes-sigs.github.io/external-dns/) si vous voulez que le cluster gère la zone tout seul, choisissez ce qui correspond à votre setup et à votre appétit.

Une fois les records créés, vérifiez la propagation :

```bash
dig +short staging.mon-domaine.tld
dig +short prod.mon-domaine.tld
# Doivent renvoyer XX.XX.XX.XX tous les deux.
```

> ⏱️ Les caches DNS publics (1.1.1.1, 8.8.8.8) reflètent le changement en quelques secondes à quelques minutes selon le registrar. Attendre que `dig` renvoie la bonne IP **avant** d'appliquer les manifestes K8s : sinon le challenge HTTP-01 échouera au premier essai (cert-manager refera des tentatives, mais autant éviter).
>
> 💡 Certains registrars imposent un TTL par défaut élevé (jusqu'à 24h). Avant le premier bootstrap, **baissez-le à 300-600 s** (5-10 min) sur vos records : si vous vous trompez d'IP ou si vous devez rebasculer, la propagation est quasi instantanée. Vous pourrez remonter à 3600 s une fois tout validé.

---

## Étape 7 · Le bootstrap complet

Dans l'ordre, sur le VPS et en local :

```bash
# 1. Sur le VPS (compte mon-admin sudoer) : installer K3s
ssh mon-admin@XX.XX.XX.XX \
  'curl -sfL https://get.k3s.io | \
    sudo INSTALL_K3S_EXEC="--write-kubeconfig-mode 644 --tls-san XX.XX.XX.XX" sh -'

# 2. Sur le VPS : créer l'utilisateur deploy dédié à la CI
ssh mon-admin@XX.XX.XX.XX '
  sudo useradd -m -s /bin/bash deploy
  sudo install -o deploy -g deploy -m 0600 -D /etc/rancher/k3s/k3s.yaml /home/deploy/.kube/config
  sudo install -o deploy -g deploy -m 0700 -d /home/deploy/.ssh
  sudo install -o deploy -g deploy -m 0600 /dev/null /home/deploy/.ssh/authorized_keys
'

# 3. Sur le VPS : générer la clé SSH dédiée au déploiement
#    et l'installer dans authorized_keys du user deploy
ssh mon-admin@XX.XX.XX.XX '
  sudo -u deploy ssh-keygen -t ed25519 -N "" -C "gh-actions-deploy" \
    -f /home/deploy/.ssh/gh_deploy_ed25519 <<< y
  sudo tee -a /home/deploy/.ssh/authorized_keys \
    < /home/deploy/.ssh/gh_deploy_ed25519.pub >/dev/null
  sudo cat /home/deploy/.ssh/gh_deploy_ed25519   # ← à copier comme secret GitHub SSH_PRIVATE_KEY
'

# 4. En local : récupérer le kubeconfig (fichier d'identification pour kubectl)
scp mon-admin@XX.XX.XX.XX:/etc/rancher/k3s/k3s.yaml ~/.kube/config-monvps
sed -i '' "s|server: https://127.0.0.1:6443|server: https://XX.XX.XX.XX:6443|" \
  ~/.kube/config-monvps
export KUBECONFIG=~/.kube/config-monvps

# 5. En local : installer cert-manager + ClusterIssuers
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
kubectl -n cert-manager rollout status deployment/cert-manager --timeout=120s
kubectl apply -f k8s/cert-manager/cluster-issuers.yaml

# 6. Chez votre registrar DNS : créer les deux records A (staging + prod → IP du VPS)
#    Interface web, CLI, API, ExternalDNS, ce que vous préférez.
#    Vérifier : dig +short staging.mon-domaine.tld → XX.XX.XX.XX

# 7. En local : appliquer les manifestes K8s
kubectl apply -f k8s/staging/namespace.yaml
kubectl apply -f k8s/prod/namespace.yaml
kubectl apply -f k8s/staging/
kubectl apply -f k8s/prod/

# 8. En local : créer les APP_SECRET
kubectl -n staging create secret generic app-secret \
  --from-literal=APP_SECRET=$(openssl rand -hex 32)
kubectl -n prod create secret generic app-secret \
  --from-literal=APP_SECRET=$(openssl rand -hex 32)

# 9. Configurer les secrets GitHub (via gh CLI)
gh secret set SSH_HOST --body "XX.XX.XX.XX"
gh secret set SSH_PORT --body "22"
gh secret set SSH_USER --body "deploy"
# Secret SSH_PRIVATE_KEY : coller le contenu de /home/deploy/.ssh/gh_deploy_ed25519
gh secret set SSH_PRIVATE_KEY < /tmp/gh_deploy_ed25519   # fichier que vous aurez récupéré

# 10. Pousser le code
git push origin main                    # → staging
git tag v1.0.0 && git push origin v1.0.0  # → prod
```

Le premier workflow qui tourne fait ~3 minutes de build + quelques secondes de rollout. Une fois les pods démarrés, Traefik voit l'Ingress, cert-manager enclenche le challenge ACME, Let's Encrypt délivre le certificat en moins de 30 secondes. Quatre minutes après le `git push`, l'app est en ligne en HTTPS.

---

## Checklist rapide avant de pousser

Avant votre premier `git push`, vérifiez :

- [ ] Dockerfile : `php bin/console asset-map:compile` présent dans le builder
- [ ] `config/routes.yaml` : route `/_health` déclarée hors du préfixe `/{_locale}`
- [ ] Ingress : **pas** d'annotation `traefik.ingress.kubernetes.io/router.entrypoints: websecure`
- [ ] DNS : records A des deux sous-domaines pointent bien vers l'IP du VPS (`dig +short staging.mon-domaine.tld`)
- [ ] cert-manager : `kubectl get clusterissuer` affiche `letsencrypt-prod   True`
- [ ] Secrets GitHub : `SSH_HOST`, `SSH_PORT`, `SSH_USER`, `SSH_PRIVATE_KEY` configurés
- [ ] Secrets K8s : `app-secret` créé dans les namespaces `staging` ET `prod`

Si tout est coché, le premier `git push` déclenche un build + rollout complet en ~4 minutes, et l'app est en ligne en HTTPS.

---

## Ce qu'il reste à ajouter pour aller vraiment en prod

La stack ci-dessus est fonctionnelle mais minimale. Pour de la vraie prod, il manque au moins :

- **Sauvegarde SQLite** : un `CronJob` K8s (ressource qui planifie l'exécution périodique d'un pod, comme un cron classique mais géré par le cluster) qui fait `sqlite3 .backup` quotidien vers un PVC séparé, avec rotation. Idéalement pushé vers du stockage objet (S3, Backblaze B2) via `rclone`. *Cf. mon [article sur les sauvegardes](/pas-de-backup-pas-de-prod), sans backup testé, vous n'êtes pas en prod.*
- **Monitoring** : Prometheus + Grafana (ou Netdata pour un mono-node) pour voir l'usage CPU/RAM et être alerté avant que ça crash. J'ai un [article complet sur la mise en place Prometheus + Grafana avec stress-test Symfony](/superviser-ses-conteneurs-docker-avec-prometheus-grafana-et-stressons-la-solution-avec-une-api-symfony-et-postman), adaptable trivialement à K3s via les Helm charts officiels.
- **Logs centralisés** : Loki + Promtail, ou du Better Stack / Grafana Cloud pour ne pas dépendre des logs locaux du pod.
- **Secrets externalisés** : `sealed-secrets` ou `external-secrets` pour arrêter de faire `kubectl create secret` à la main.
- **Sentry** (ou équivalent) pour les erreurs applicatives.
- **Un vrai healthcheck** : mon `/_health` actuel renvoie juste `{"status":"ok"}`. Un vrai healthcheck vérifie la connectivité DB, les dépendances externes (mailer, APIs tierces), éventuellement un ping Redis.
- **Migration vers Postgres** si le trafic augmente : SQLite est parfait pour une démo ou une petite app, mais au-delà de ~100 écritures/seconde ou si vous voulez du multi-pod, il faut passer à autre chose.

Pour aller plus loin sur d'autres approches d'orchestration, j'ai aussi écrit sur [Docker Swarm en production](/docker-swarm) (plus simple que K8s, idéal si vous n'avez pas besoin de la richesse de l'écosystème Kubernetes) et sur [du HTTPS en local via Traefik et Let's Encrypt](/du-https-en-local-avec-docker-traefik-traefik-me-et-lets-encrypt) (même principes ACME, mais côté dev).

---

## Conclusion

Ce qu'il faut retenir de cette stack :

- **FrankenPHP est un plaisir à packager**. L'image officielle Dunglas fait 90 % du taf, le Caddyfile est lisible, le worker mode gomme la latence de bootstrap du kernel Symfony.
- **K3s est tout à fait solide pour un mono-VPS**. Traefik + local-path + servicelb suffisent largement. Le jour où le projet grossit, on ajoute des nodes sans changer les manifestes.
- **Résister à la tentation de sur-outiller**. ArgoCD, Flux, ExternalDNS, sealed-secrets, Kustomize... tout ça a sa place sur un vrai cluster multi-équipes. Sur un mono-VPS, un `kubectl rollout restart` via SSH fait exactement le job, en 30 lignes de YAML.
- **Le pipeline repose sur deux commandes** : `git push` sur `main` pour staging, `git tag vX.Y.Z && git push --tags` pour prod. Le reste est automatique.

Le code complet se trouve dans [Symfony Demo](https://github.com/symfony/demo) pour la partie app. Pour la partie infra, tous les snippets de cet article sont directement réutilisables, vous remplacez les `XX.XX.XX.XX`, `mon-domaine.tld`, `votre-org/votre-app` par les vôtres, vous créez deux records DNS chez votre registrar, et vous avez un pipeline de déploiement opérationnel en une demi-journée.

Si ce format vous intéresse, dites-le-moi : je suis prêt à maintenir un repo template public avec toute cette stack prête à cloner.

---

<small>*Article écrit avec l'aide de Claude Opus 4.7.*</small>
