28 min de lecture

Symfony en prod sur un mono-VPS : FrankenPHP + K3s, staging + TLS en une demi-journée

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.

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.
Mode de lecture :

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.

🧪 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 (et ça reste encore incomplet).


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, 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

1. push image

:staging-* ou :prod-*
2. ssh + kubectl

rollout restart
3. pull image
résolution

publique
GitHub

repo + Actions
GHCR

registry d'images
VPS · K3s single-node

Traefik · servicelb · local-path
Namespace staging

staging.mon-domaine.tld

Deployment · PVC 1 Gi
Namespace prod

prod.mon-domaine.tld

Deployment · PVC 1 Gi
DNS registrar

records A

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 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.

# 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 :

php bin/console asset-map:compile

Elle est obligatoire pour AssetMapper en prod. AssetMapper 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 :

{
    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 :

#!/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 :

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 :

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

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 :

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 :

# 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) :

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 :

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.

# 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 :

# 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é.

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 :

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
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 :

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 :

SecretValeur
SSH_HOSTIP ou hostname du VPS
SSH_PORT22 (ou ce que vous avez mis)
SSH_USERdeploy (le compte non-privilégié créé à l’Étape 3)
SSH_PRIVATE_KEYclé 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 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 :

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 :

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 :

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 :

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 :

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 :

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 :

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 :

NomTypeValeurTTL
staging.mon-domaine.tldAXX.XX.XX.XX3600
prod.mon-domaine.tldAXX.XX.XX.XX3600

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 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 :

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 :

# 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, 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, 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 (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 (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 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.


Article écrit avec l’aide de Claude Opus 4.7.

Back to Blog

Comments (0)

Loading comments...

Leave a Comment