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 restartvia SSH- Des manifestes K3s dupliqués pour
stagingetprod- 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
PersistentVolumeClaimlocal-pathPattern de release :
pushsurmain→ 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 | shet 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
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
staginget un pourprod: 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 purworker /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, unServiceLoadBalancerdé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é avecimagePullPolicy: Alwayssur 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 statusbloque 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 pushsurmain→ image:staging-latest+:staging-<sha>→ rollout auto en staginggit 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 auproject_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 estRollingUpdate(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.Recreatefait 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 enRollingUpdate.
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 chaquekubectl rollout restartdé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, leServicene 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:requestsest la quantité de CPU/RAM garantie (utilisée par le scheduler pour placer le pod),limitsest 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: websecuresur 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 surweb(80) ETwebsecure(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 :
| 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 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
digrenvoie 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:compileprésent dans le builder -
config/routes.yaml: route/_healthdé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 clusterissuerafficheletsencrypt-prod True - Secrets GitHub :
SSH_HOST,SSH_PORT,SSH_USER,SSH_PRIVATE_KEYconfigurés - Secrets K8s :
app-secretcréé dans les namespacesstagingETprod
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
CronJobK8s (ressource qui planifie l’exécution périodique d’un pod, comme un cron classique mais géré par le cluster) qui faitsqlite3 .backupquotidien vers un PVC séparé, avec rotation. Idéalement pushé vers du stockage objet (S3, Backblaze B2) viarclone. 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-secretsouexternal-secretspour arrêter de fairekubectl create secretà la main. - Sentry (ou équivalent) pour les erreurs applicatives.
- Un vrai healthcheck : mon
/_healthactuel 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 restartvia SSH fait exactement le job, en 30 lignes de YAML. - Le pipeline repose sur deux commandes :
git pushsurmainpour staging,git tag vX.Y.Z && git push --tagspour 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.
Loading comments...