◀ Retour au blog
Linux

Déployer une application Symfony sur un VPS : Nginx, PHP-FPM, MySQL et HTTPS

Publié le 08 Sep 2026· 13 min de lecture
#Linux#Symfony#Nginx#PHP-FPM#MySQL

Du VPS nu à l'application en production

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 Ubuntu 24.04 LTS fraîchement installé et aboutit à une application servie en HTTPS, avec ses workers et ses tâches planifiées.

La sécurisation de base (utilisateur non-root, clés SSH, pare-feu, fail2ban) est détaillée dans l'article Sécurisation d'un serveur Linux : faites-la en premier.

1. Paquets de base

Ubuntu 24.04 fournit PHP 8.3 dans ses dépôts officiels, avec les extensions nécessaires à Symfony et Doctrine :

sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mysql-server unzip git \
  php8.3-fpm php8.3-cli php8.3-mysql php8.3-intl php8.3-mbstring \
  php8.3-xml php8.3-curl php8.3-zip php8.3-opcache

# Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

Ouvrez le pare-feu pour le web (en plus de SSH) :

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

2. Un utilisateur système par application

Chaque application tourne sous son propre utilisateur : une faille dans l'une ne donne pas accès aux fichiers des autres.

sudo adduser --system --group --home /var/www/app --shell /bin/bash app
sudo mkdir -p /var/www/app && sudo chown app:app /var/www/app

3. Un pool PHP-FPM dédié

Plutôt que le pool www par défaut, créez /etc/php/8.3/fpm/pool.d/app.conf. Le pool tourne avec l'utilisateur app, et seul Nginx (www-data) peut parler à son socket :

[app]
user = app
group = app

listen = /run/php/app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php/app-error.log
php_admin_flag[log_errors] = on

Pour dimensionner pm.max_children : divisez la RAM allouée à PHP par la mémoire moyenne d'un processus (visible avec ps -o rss -C php-fpm8.3). Sur un VPS de 4 Go, 20 processus à 80 Mo laissent de la marge pour MySQL.

sudo mkdir -p /var/log/php && sudo chown app:app /var/log/php
sudo systemctl restart php8.3-fpm

4. OPcache réglé pour la production

Dans /etc/php/8.3/fpm/conf.d/99-production.ini :

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
realpath_cache_size=4096K
realpath_cache_ttl=600

Avec validate_timestamps=0, PHP ne vérifie plus si les fichiers ont changé : il faut recharger PHP-FPM à chaque déploiement. C'est le prix d'un gain de performance important.

5. MySQL : une base et un utilisateur dédiés

sudo mysql
CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app'@'localhost' IDENTIFIED BY 'un-mot-de-passe-long-et-aleatoire';
GRANT ALL PRIVILEGES ON app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;

MySQL n'écoute que sur 127.0.0.1 par défaut sur Ubuntu : gardez-le ainsi, et ne l'exposez jamais sur Internet.

6. Le virtual host Nginx

Voici la configuration recommandée pour Symfony, dans /etc/nginx/sites-available/app. Seul index.php est exécutable : tout autre fichier .php renvoie une 404, ce qui neutralise les scripts qui auraient été uploadés.

server {
    listen 80;
    server_name app.example.com;
    root /var/www/app/current/public;

    client_max_body_size 20M;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/app.sock;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }

    location ~ \.php$ {
        return 404;
    }

    location ~* \.(?:css|js|woff2|svg|png|jpg|webp)$ {
        expires 30d;
        access_log off;
    }

    error_log /var/log/nginx/app_error.log;
    access_log /var/log/nginx/app_access.log;
}

$realpath_root (au lieu de $document_root) est essentiel si vous déployez via un lien symbolique current : Nginx résout le vrai chemin, et OPcache ne sert pas l'ancienne version après une bascule.

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

7. HTTPS avec Let's Encrypt

Une fois le DNS pointé vers le serveur, Certbot obtient le certificat, modifie la configuration Nginx et ajoute la redirection HTTP vers HTTPS :

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-run   # vérifie le renouvellement automatique

8. Déployer le code

Une structure releases/ + lien current permet des déploiements atomiques et un retour arrière instantané :

sudo -iu app
cd /var/www/app
RELEASE=releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 [email protected]:moi/app.git "$RELEASE"
cd "$RELEASE"

composer install --no-dev --optimize-autoloader --classmap-authoritative
composer dump-env prod          # compile .env en .env.local.php
php bin/console cache:clear
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console asset-map:compile   # si vous utilisez AssetMapper

cd /var/www/app && ln -sfn "$RELEASE" current
exit
sudo systemctl reload php8.3-fpm

Les secrets (DATABASE_URL, APP_SECRET) se placent dans un fichier .env.local partagé entre les releases, ou mieux, dans le coffre de secrets de Symfony (secrets:set). Des outils comme Deployer automatisent exactement ce cycle.

9. Workers Messenger avec systemd

Un worker Messenger doit redémarrer s'il plante, et être relancé régulièrement pour libérer la mémoire. Créez /etc/systemd/system/[email protected] :

[Unit]
Description=Symfony Messenger worker %i
After=network.target mysql.service

[Service]
User=app
WorkingDirectory=/var/www/app/current
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now app-messenger@1 app-messenger@2

Pensez à ajouter php bin/console messenger:stop-workers à la fin du déploiement : les workers terminent leur message en cours, puis systemd les relance sur le nouveau code.

10. Tâches planifiées

Avec Symfony Scheduler, un seul worker suffit (messenger:consume scheduler_default). Sinon, la crontab de l'utilisateur app (sudo crontab -u app -e) :

*/5 * * * * cd /var/www/app/current && php bin/console app:sync-data --no-interaction >> /var/log/php/cron.log 2>&1

La checklist finale

  • APP_ENV=prod et APP_DEBUG=0 : jamais de profiler en production
  • Pare-feu actif, seuls les ports 22, 80 et 443 ouverts
  • MySQL et Redis n'écoutent que sur localhost
  • Certificat renouvelé automatiquement (systemctl list-timers | grep certbot)
  • Logs rotés (logrotate gère Nginx ; ajoutez /var/log/php/*.log)
  • Sauvegardes automatiques et testées de la base et des fichiers uploadés
  • Monitoring des erreurs (Sentry) et de la disponibilité

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.