[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"article:fr:symfony-vps-deployment":3},{"html":4,"lang":5},"\u003Ch2>Du VPS nu à l'application en production\u003C\u002Fh2>\n\u003Cp>Docker et les PaaS sont pratiques, mais un simple VPS bien configuré reste une option solide, économique et parfaitement maîtrisée pour une application Symfony. Ce guide part d'un serveur \u003Cstrong>Ubuntu 24.04 LTS\u003C\u002Fstrong> fraîchement installé et aboutit à une application servie en HTTPS, avec ses workers et ses tâches planifiées.\u003C\u002Fp>\n\u003Cp>La sécurisation de base (utilisateur non-root, clés SSH, pare-feu, fail2ban) est détaillée dans l'article \u003Ca href=\"\u002Fblog\u002Flinux-server-hardening\">Sécurisation d'un serveur Linux\u003C\u002Fa> : faites-la en premier.\u003C\u002Fp>\n\n\u003Ch3>1. Paquets de base\u003C\u002Fh3>\n\u003Cp>Ubuntu 24.04 fournit PHP 8.3 dans ses dépôts officiels, avec les extensions nécessaires à Symfony et Doctrine :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo apt update &amp;&amp; sudo apt upgrade -y\nsudo apt install -y nginx mysql-server unzip git \\\n  php8.3-fpm php8.3-cli php8.3-mysql php8.3-intl php8.3-mbstring \\\n  php8.3-xml php8.3-curl php8.3-zip php8.3-opcache\n\n# Composer\ncurl -sS https:\u002F\u002Fgetcomposer.org\u002Finstaller | php\nsudo mv composer.phar \u002Fusr\u002Flocal\u002Fbin\u002Fcomposer\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Ouvrez le pare-feu pour le web (en plus de SSH) :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo ufw allow OpenSSH\nsudo ufw allow 'Nginx Full'\nsudo ufw enable\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>2. Un utilisateur système par application\u003C\u002Fh3>\n\u003Cp>Chaque application tourne sous son propre utilisateur : une faille dans l'une ne donne pas accès aux fichiers des autres.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo adduser --system --group --home \u002Fvar\u002Fwww\u002Fapp --shell \u002Fbin\u002Fbash app\nsudo mkdir -p \u002Fvar\u002Fwww\u002Fapp &amp;&amp; sudo chown app:app \u002Fvar\u002Fwww\u002Fapp\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>3. Un pool PHP-FPM dédié\u003C\u002Fh3>\n\u003Cp>Plutôt que le pool \u003Ccode>www\u003C\u002Fcode> par défaut, créez \u003Ccode>\u002Fetc\u002Fphp\u002F8.3\u002Ffpm\u002Fpool.d\u002Fapp.conf\u003C\u002Fcode>. Le pool tourne avec l'utilisateur \u003Ccode>app\u003C\u002Fcode>, et seul Nginx (\u003Ccode>www-data\u003C\u002Fcode>) peut parler à son socket :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>[app]\nuser = app\ngroup = app\n\nlisten = \u002Frun\u002Fphp\u002Fapp.sock\nlisten.owner = www-data\nlisten.group = www-data\nlisten.mode = 0660\n\npm = dynamic\npm.max_children = 20\npm.start_servers = 4\npm.min_spare_servers = 2\npm.max_spare_servers = 6\npm.max_requests = 500\n\nphp_admin_value[memory_limit] = 256M\nphp_admin_value[error_log] = \u002Fvar\u002Flog\u002Fphp\u002Fapp-error.log\nphp_admin_flag[log_errors] = on\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Pour dimensionner \u003Ccode>pm.max_children\u003C\u002Fcode> : divisez la RAM allouée à PHP par la mémoire moyenne d'un processus (visible avec \u003Ccode>ps -o rss -C php-fpm8.3\u003C\u002Fcode>). Sur un VPS de 4 Go, 20 processus à 80 Mo laissent de la marge pour MySQL.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo mkdir -p \u002Fvar\u002Flog\u002Fphp &amp;&amp; sudo chown app:app \u002Fvar\u002Flog\u002Fphp\nsudo systemctl restart php8.3-fpm\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>4. OPcache réglé pour la production\u003C\u002Fh3>\n\u003Cp>Dans \u003Ccode>\u002Fetc\u002Fphp\u002F8.3\u002Ffpm\u002Fconf.d\u002F99-production.ini\u003C\u002Fcode> :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>opcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\nrealpath_cache_size=4096K\nrealpath_cache_ttl=600\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Avec \u003Ccode>validate_timestamps=0\u003C\u002Fcode>, PHP ne vérifie plus si les fichiers ont changé : il faut \u003Cstrong>recharger PHP-FPM à chaque déploiement\u003C\u002Fstrong>. C'est le prix d'un gain de performance important.\u003C\u002Fp>\n\n\u003Ch3>5. MySQL : une base et un utilisateur dédiés\u003C\u002Fh3>\n\u003Cpre>\u003Ccode>sudo mysql\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode>CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\nCREATE USER 'app'@'localhost' IDENTIFIED BY 'un-mot-de-passe-long-et-aleatoire';\nGRANT ALL PRIVILEGES ON app.* TO 'app'@'localhost';\nFLUSH PRIVILEGES;\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>MySQL n'écoute que sur \u003Ccode>127.0.0.1\u003C\u002Fcode> par défaut sur Ubuntu : gardez-le ainsi, et ne l'exposez jamais sur Internet.\u003C\u002Fp>\n\n\u003Ch3>6. Le virtual host Nginx\u003C\u002Fh3>\n\u003Cp>Voici la configuration recommandée pour Symfony, dans \u003Ccode>\u002Fetc\u002Fnginx\u002Fsites-available\u002Fapp\u003C\u002Fcode>. Seul \u003Ccode>index.php\u003C\u002Fcode> est exécutable : tout autre fichier \u003Ccode>.php\u003C\u002Fcode> renvoie une 404, ce qui neutralise les scripts qui auraient été uploadés.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>server {\n    listen 80;\n    server_name app.example.com;\n    root \u002Fvar\u002Fwww\u002Fapp\u002Fcurrent\u002Fpublic;\n\n    client_max_body_size 20M;\n\n    location \u002F {\n        try_files $uri \u002Findex.php$is_args$args;\n    }\n\n    location ~ ^\u002Findex\\.php(\u002F|$) {\n        fastcgi_pass unix:\u002Frun\u002Fphp\u002Fapp.sock;\n        fastcgi_split_path_info ^(.+\\.php)(\u002F.*)$;\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n        fastcgi_param DOCUMENT_ROOT $realpath_root;\n        internal;\n    }\n\n    location ~ \\.php$ {\n        return 404;\n    }\n\n    location ~* \\.(?:css|js|woff2|svg|png|jpg|webp)$ {\n        expires 30d;\n        access_log off;\n    }\n\n    error_log \u002Fvar\u002Flog\u002Fnginx\u002Fapp_error.log;\n    access_log \u002Fvar\u002Flog\u002Fnginx\u002Fapp_access.log;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>$realpath_root\u003C\u002Fcode> (au lieu de \u003Ccode>$document_root\u003C\u002Fcode>) est essentiel si vous déployez via un lien symbolique \u003Ccode>current\u003C\u002Fcode> : Nginx résout le vrai chemin, et OPcache ne sert pas l'ancienne version après une bascule.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo ln -s \u002Fetc\u002Fnginx\u002Fsites-available\u002Fapp \u002Fetc\u002Fnginx\u002Fsites-enabled\u002F\nsudo rm \u002Fetc\u002Fnginx\u002Fsites-enabled\u002Fdefault\nsudo nginx -t &amp;&amp; sudo systemctl reload nginx\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>7. HTTPS avec Let's Encrypt\u003C\u002Fh3>\n\u003Cp>Une fois le DNS pointé vers le serveur, Certbot obtient le certificat, modifie la configuration Nginx et ajoute la redirection HTTP vers HTTPS :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo apt install -y certbot python3-certbot-nginx\nsudo certbot --nginx -d app.example.com\nsudo certbot renew --dry-run   # vérifie le renouvellement automatique\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>8. Déployer le code\u003C\u002Fh3>\n\u003Cp>Une structure \u003Ccode>releases\u002F\u003C\u002Fcode> + lien \u003Ccode>current\u003C\u002Fcode> permet des déploiements atomiques et un retour arrière instantané :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>sudo -iu app\ncd \u002Fvar\u002Fwww\u002Fapp\nRELEASE=releases\u002F$(date +%Y%m%d%H%M%S)\ngit clone --depth 1 git@github.com:moi\u002Fapp.git \"$RELEASE\"\ncd \"$RELEASE\"\n\ncomposer install --no-dev --optimize-autoloader --classmap-authoritative\ncomposer dump-env prod          # compile .env en .env.local.php\nphp bin\u002Fconsole cache:clear\nphp bin\u002Fconsole doctrine:migrations:migrate --no-interaction\nphp bin\u002Fconsole asset-map:compile   # si vous utilisez AssetMapper\n\ncd \u002Fvar\u002Fwww\u002Fapp &amp;&amp; ln -sfn \"$RELEASE\" current\nexit\nsudo systemctl reload php8.3-fpm\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Les secrets (\u003Ccode>DATABASE_URL\u003C\u002Fcode>, \u003Ccode>APP_SECRET\u003C\u002Fcode>) se placent dans un fichier \u003Ccode>.env.local\u003C\u002Fcode> partagé entre les releases, ou mieux, dans le coffre de secrets de Symfony (\u003Ccode>secrets:set\u003C\u002Fcode>). Des outils comme Deployer automatisent exactement ce cycle.\u003C\u002Fp>\n\n\u003Ch3>9. Workers Messenger avec systemd\u003C\u002Fh3>\n\u003Cp>Un worker Messenger doit redémarrer s'il plante, et être relancé régulièrement pour libérer la mémoire. Créez \u003Ccode>\u002Fetc\u002Fsystemd\u002Fsystem\u002Fapp-messenger@.service\u003C\u002Fcode> :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>[Unit]\nDescription=Symfony Messenger worker %i\nAfter=network.target mysql.service\n\n[Service]\nUser=app\nWorkingDirectory=\u002Fvar\u002Fwww\u002Fapp\u002Fcurrent\nExecStart=\u002Fusr\u002Fbin\u002Fphp bin\u002Fconsole messenger:consume async --time-limit=3600 --memory-limit=256M\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode>sudo systemctl daemon-reload\nsudo systemctl enable --now app-messenger@1 app-messenger@2\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Pensez à ajouter \u003Ccode>php bin\u002Fconsole messenger:stop-workers\u003C\u002Fcode> à la fin du déploiement : les workers terminent leur message en cours, puis systemd les relance sur le nouveau code.\u003C\u002Fp>\n\n\u003Ch3>10. Tâches planifiées\u003C\u002Fh3>\n\u003Cp>Avec Symfony Scheduler, un seul worker suffit (\u003Ccode>messenger:consume scheduler_default\u003C\u002Fcode>). Sinon, la crontab de l'utilisateur \u003Ccode>app\u003C\u002Fcode> (\u003Ccode>sudo crontab -u app -e\u003C\u002Fcode>) :\u003C\u002Fp>\n\u003Cpre>\u003Ccode>*\u002F5 * * * * cd \u002Fvar\u002Fwww\u002Fapp\u002Fcurrent &amp;&amp; php bin\u002Fconsole app:sync-data --no-interaction &gt;&gt; \u002Fvar\u002Flog\u002Fphp\u002Fcron.log 2&gt;&amp;1\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>La checklist finale\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Ccode>APP_ENV=prod\u003C\u002Fcode> et \u003Ccode>APP_DEBUG=0\u003C\u002Fcode> : jamais de profiler en production\u003C\u002Fli>\n\u003Cli>Pare-feu actif, seuls les ports 22, 80 et 443 ouverts\u003C\u002Fli>\n\u003Cli>MySQL et Redis n'écoutent que sur localhost\u003C\u002Fli>\n\u003Cli>Certificat renouvelé automatiquement (\u003Ccode>systemctl list-timers | grep certbot\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>Logs rotés (\u003Ccode>logrotate\u003C\u002Fcode> gère Nginx ; ajoutez \u003Ccode>\u002Fvar\u002Flog\u002Fphp\u002F*.log\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>Sauvegardes automatiques et \u003Cstrong>testées\u003C\u002Fstrong> de la base et des fichiers uploadés\u003C\u002Fli>\n\u003Cli>Monitoring des erreurs (Sentry) et de la disponibilité\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Cette configuration encaisse sans difficulté plusieurs milliers d'utilisateurs quotidiens sur un VPS à quelques euros par mois. Et comme chaque brique est standard, elle se diagnostique facilement quand quelque chose ne va pas.\u003C\u002Fp>\n","fr",1790546629190]