
August 12, 2026
10 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Symfony deployment on Ubuntu VPS step by step requires coordinating PHP-FPM, Nginx, database services, and file permissions into a repeatable release workflow. Many developers get the application running locally but struggle when translating that to a hardened production environment on Ubuntu 24.04 LTS. This guide walks through the exact configuration I use for client projects, including legal-tech portals and booking platforms, where reliability matters more than experimental infrastructure. If you are evaluating backend frameworks for a new project, understanding this operational baseline helps compare against options like Laravel development services which share similar PHP-FPM deployment patterns.
How do you prepare Ubuntu 24.04 for Symfony deployment?
Before touching Symfony code, the server must be provisioned correctly. On a fresh Ubuntu 24.04 LTS VPS, start by updating packages and installing only what the runtime actually needs. Avoid installing Node.js or build tools on production; compile assets locally or in CI and upload artifacts instead.
sudo apt update && sudo apt upgrade -y
sudo apt install -y software-properties-common apt-transport-https lsb-release ca-certificates
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install -y php8.4-fpm php8.4-cli php8.4-pgsql php8.4-mysql \
php8.4-intl php8.4-zip php8.4-xml php8.4-curl php8.4-mbstring \
php8.4-opcache php8.4-gd nginx postgresql-16 git unzip acl The ondrej/php PPA remains the standard source for current PHP versions on Ubuntu in 2026. While Ubuntu 24.04 ships PHP 8.3 natively, Symfony 7.x benefits from PHP 8.4's improved performance and type system features. Always pin your PHP version explicitly rather than relying on meta-packages that may shift during upgrades.
File system permissions and ACLs
Symfony requires write access to var/cache, var/log, and optionally var/sessions. On shared hosting this is often misconfigured, leading to cryptic permission errors after deployment. Use POSIX ACLs to grant both the deploy user and www-data persistent access without resorting to chmod 777:
sudo setfacl -dR -m u:www-data:rwX -m u:$USER:rwX /var/www/symfony-app/var
sudo setfacl -R -m u:www-data:rwX -m u:$USER:rwX /var/www/symfony-app/var This ensures newly created cache and log files inherit correct permissions automatically. I have debugged too many production incidents caused by cache warmup failing because the web server user could not write to a directory owned by the deploy user.
How do you configure Nginx and PHP-FPM for Symfony 7?
Nginx serves as the HTTP front-end, passing PHP requests to FPM over a Unix socket. The official Symfony documentation provides a solid baseline, but production configurations need specific tuning for security and performance that defaults omit.
Nginx virtual host configuration
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
root /var/www/symfony-app/current/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
internal;
}
location ~ \.php$ {
return 404;
}
access_log /var/log/nginx/symfony_access.log combined buffer=32k flush=5s;
error_log /var/log/nginx/symfony_error.log warn;
} Critical details here: the internal; directive inside the index.php location block prevents direct access to PHP files other than the front controller. Without this, an attacker might execute uploaded scripts if your application handles file uploads. The second \.php$ block returning 404 acts as a safety net. Also note $realpath_root instead of $document_root; this resolves symlinks correctly when using atomic deployments.
PHP-FPM pool tuning
Edit /etc/php/8.4/fpm/pool.d/www.conf to match your VPS resources. For a 4GB RAM VPS running PostgreSQL and Redis alongside PHP:
[www]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
php_admin_value[error_log] = /var/log/php8.4-fpm-error.log
php_admin_flag[log_errors] = on The pm.max_children value depends on available RAM after accounting for database and OS overhead. Each PHP-FPM worker consumes roughly 40–80MB depending on loaded extensions and application complexity. Setting this too high causes OOM kills under load; too low leaves CPU idle during traffic spikes. Monitor with systemctl status php8.4-fpm and adjust based on actual memory usage.
What OPcache settings maximize Symfony performance in production?
OPcache is non-negotiable for Symfony in production. Without it, every request recompiles dozens of PHP files. With proper tuning, you eliminate most filesystem overhead entirely.
Edit /etc/php/8.4/fpm/conf.d/10-opcache.ini:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.jit=disable Set validate_timestamps=0 in production. This tells OPcache to never check if source files changed, eliminating stat() syscalls on every request. The trade-off is that code changes require explicit cache clearing — which your deployment process handles anyway via php bin/console cache:clear or FPM reload.
I disable JIT (opcache.jit=disable) for typical Symfony web applications. JIT benefits CPU-bound numerical workloads, but Symfony requests are predominantly I/O-bound (database queries, template rendering, serialization). In my experience across multiple client projects, JIT adds memory overhead and occasional edge-case bugs without measurable latency improvement for CRUD-heavy applications. Benchmark your specific workload before enabling it.
| Setting | Development | Production | Rationale |
|---|---|---|---|
opcache.enable | 1 | 1 | Always enabled; dev uses validate_timestamps instead |
validate_timestamps | 1 | 0 | Eliminates filesystem checks; deploy handles invalidation |
max_accelerated_files | 10000 | 20000+ | Symfony + vendor easily exceeds 10k files |
memory_consumption | 128 | 256 | Prevents partial caching under memory pressure |
opcache.jit | disable | disable* | *Enable only after benchmarking CPU-bound workloads |
How do you automate zero-downtime Symfony deployment on Ubuntu?
Manual deployments via SSH and git pull cause downtime during composer install, cache clearing, and asset building. Symlinked release directories solve this: prepare everything in an isolated directory, then atomically swap the current symlink.
Directory structure convention
/var/www/symfony-app/
├── current → releases/20260812103000
├── releases/
│ ├── 20260812103000/
│ ├── 20260811091500/
│ └── 20260810080000/
└── shared/
├── .env.local
├── var/
│ ├── log/
│ └── sessions/
└── vendor/ (optional: shared vendor for faster deploys) The shared/ directory persists across releases. Environment variables containing secrets live in shared/.env.local, never committed to Git. Logs and session files also persist so debugging survives deployments.
Deployer 7 configuration
Deployer remains the most practical tool for PHP deployments in 2026. Install globally via composer global require deployer/deployer:^7.0 and create deploy.php:
<?php
namespace Deployer;
require 'recipe/symfony.php';
set('application', 'symfony-app');
set('repository', 'git@github.com:org/symfony-app.git');
set('php_version', '8.4');
set('bin/php', '/usr/bin/php8.4');
set('bin/composer', '/usr/local/bin/composer');
host('production')
->setHostname('your-vps-ip')
->setRemoteUser('deploy')
->setDeployPath('/var/www/symfony-app')
->setLabels(['stage' => 'prod']);
add('shared_files', ['.env.local']);
add('shared_dirs', ['var/log', 'var/sessions']);
add('writable_dirs', ['var/cache', 'var/log', 'var/sessions']);
after('deploy:symlink', 'php-fpm:reload');
task('php-fpm:reload', function () {
run('sudo systemctl reload php8.4-fpm');
});
desc('Deploys Symfony application');
task('deploy', [
'deploy:prepare',
'deploy:vendors',
'deploy:publish',
]); The critical line is after('deploy:symlink', 'php-fpm:reload'). Since validate_timestamps=0, OPcache will serve stale bytecode until FPM workers restart. Reloading (not restarting) gracefully cycles workers without dropping active connections. Ensure the deploy user has passwordless sudo for this specific command via /etc/sudoers.d/deploy-php:
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload php8.4-fpm How do you handle environment variables and secrets securely?
Never commit .env.local to version control. Symfony's dotenv component loads .env.local automatically in production, overriding values from .env. Store the production file exclusively in shared/.env.local on the server, managed outside Git.
# /var/www/symfony-app/shared/.env.local
APP_ENV=prod
APP_SECRET=your-64-char-random-secret-here
DATABASE_URL="postgresql://app_user:secure_password@127.0.0.1:5432/symfony_prod?serverVersion=16&charset=utf8"
REDIS_URL=redis://127.0.0.1:6379
MAILER_DSN=smtp://mail.example.com:587?encryption=tls Generate APP_SECRET with openssl rand -hex 32. Restrict file permissions: chmod 600 shared/.env.local owned by the deploy user. During deployment, Deployer symlinks this file into each release directory automatically via the shared_files configuration shown earlier.
For teams managing multiple environments, consider HashiCorp Vault or SOPS-encrypted secrets in Git. But for most Nepal-based clients and small-to-medium projects I have worked on, the shared file approach with strict permissions is simpler, auditable, and sufficient. Complexity should match actual threat models, not hypothetical ones.
Conclusion
Symfony deployment on Ubuntu VPS step by step becomes straightforward once you treat infrastructure as part of the application, not an afterthought. Correct PHP-FPM tuning, OPcache configuration, Nginx hardening, and atomic symlinked releases form a reliable foundation that scales from single-server setups to multi-node clusters. Test your deployment pipeline on a staging VPS before touching production, and always verify FPM reload actually clears OPcache after each deploy. If you need help configuring this for your specific workload or want to evaluate whether Symfony or Laravel better fits your next project, get in touch to discuss your requirements. For teams exploring related deployment patterns, the CI/CD pipeline setup guide covers integrating these steps into GitLab CI automation.

