◀ Back to blog
Linux

Deploying Symfony on a VPS: Nginx, PHP-FPM, MySQL, HTTPS

Published on 08 Sep 2026· 7 min read
#Linux#Symfony#Nginx#PHP-FPM#MySQL

From a bare VPS to a production application

Docker and PaaS platforms are convenient, but a well-configured VPS remains a solid, affordable and fully controlled option for a Symfony application. This guide starts from a freshly installed Ubuntu 24.04 LTS server and ends with an application served over HTTPS, with its workers and scheduled tasks.

Basic hardening (non-root user, SSH keys, firewall, fail2ban) is covered in the Linux server hardening article: do it first.

1. Base packages

Ubuntu 24.04 ships PHP 8.3 in its official repositories, with the extensions Symfony and Doctrine need:

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

Open the firewall for the web (in addition to SSH):

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

2. One system user per application

Each application runs under its own user: a flaw in one of them does not give access to the files of the others.

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. A dedicated PHP-FPM pool

Instead of the default www pool, create /etc/php/8.3/fpm/pool.d/app.conf. The pool runs as the app user, and only Nginx (www-data) can talk to its 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

To size pm.max_children, divide the RAM you give to PHP by the average memory of a process (see ps -o rss -C php-fpm8.3). On a 4 GB VPS, 20 processes at 80 MB leave room for MySQL.

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

4. OPcache tuned for production

In /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

With validate_timestamps=0, PHP no longer checks whether files have changed: you must reload PHP-FPM on every deployment. That is the price of a significant performance gain.

5. MySQL: a dedicated database and user

sudo mysql
CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;

On Ubuntu, MySQL only listens on 127.0.0.1 by default: keep it that way, and never expose it to the Internet.

6. The Nginx virtual host

Here is the recommended configuration for Symfony, in /etc/nginx/sites-available/app. Only index.php can be executed: any other .php file returns a 404, which neutralizes scripts that may have been uploaded.

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 (instead of $document_root) is essential if you deploy through a current symlink: Nginx resolves the real path, and OPcache does not keep serving the old release after a switch.

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 with Let's Encrypt

Once the DNS points to the server, Certbot obtains the certificate, updates the Nginx configuration and adds the HTTP to HTTPS redirect:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-run   # checks automatic renewal

8. Deploying the code

A releases/ directory plus a current symlink gives atomic deployments and instant rollbacks:

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

composer install --no-dev --optimize-autoloader --classmap-authoritative
composer dump-env prod          # compiles .env into .env.local.php
php bin/console cache:clear
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console asset-map:compile   # if you use AssetMapper

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

Secrets (DATABASE_URL, APP_SECRET) go in a .env.local file shared between releases or, better, in Symfony's secrets vault (secrets:set). Tools such as Deployer automate exactly this cycle.

9. Messenger workers with systemd

A Messenger worker must restart if it crashes, and be restarted regularly to free memory. Create /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

Add php bin/console messenger:stop-workers at the end of the deployment: workers finish their current message, then systemd restarts them on the new code.

10. Scheduled tasks

With Symfony Scheduler, a single worker is enough (messenger:consume scheduler_default). Otherwise, use the app user's crontab (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

Final checklist

  • APP_ENV=prod and APP_DEBUG=0: never the profiler in production
  • Firewall enabled, only ports 22, 80 and 443 open
  • MySQL and Redis only listen on localhost
  • Certificate renewed automatically (systemctl list-timers | grep certbot)
  • Logs rotated (logrotate handles Nginx; add /var/log/php/*.log)
  • Automatic, tested backups of the database and uploaded files
  • Error monitoring (Sentry) and uptime monitoring

This setup easily handles several thousand daily users on a VPS costing a few euros a month. And since every building block is standard, it is easy to troubleshoot when something goes wrong.