Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Symfony Deployment on Ubuntu VPS Step by Step

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.

Ubuntu 24.04 VPS Runtime StackNginxReverse ProxyPHP 8.4-FPMApp RuntimePostgreSQL 16Primary DBRedis 7.4Cache/Session/var/www/symfony-app (Symlinked Releases)releases/20260812103000/Immutable Artifactshared/.env.localPersistent Secretsshared/var/logPersistent Logscurrent →Active ReleaseDeployer swaps 'current' symlink atomically → Zero Downtime
Production Symfony deployment architecture on Ubuntu VPS with symlinked releases and shared persistent state

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.

SettingDevelopmentProductionRationale
opcache.enable11Always enabled; dev uses validate_timestamps instead
validate_timestamps10Eliminates filesystem checks; deploy handles invalidation
max_accelerated_files1000020000+Symfony + vendor easily exceeds 10k files
memory_consumption128256Prevents partial caching under memory pressure
opcache.jitdisabledisable**Enable only after benchmarking CPU-bound workloads
Without OPcacheHTTP Request → NginxPHP-FPM WorkerRead + Parse + Compile ALL FilesExecute Application LogicReturn Response~80-200ms overhead per requestWith OPcache (Production)HTTP Request → NginxPHP-FPM WorkerLoad Precompiled Bytecode from RAMExecute Application LogicReturn Response~2-5ms overhead per request
OPcache eliminates per-request file compilation, reducing Symfony response overhead by 90%+ in production

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
Zero-Downtime Deployment SequenceStep 1: PrepareCreate timestamped dirunder releases/git clone --depth 1composer install --no-devLink shared/.env.localcache:warmupOld release still servestraffic via 'current'Step 2: Atomic Swapln -sfn releases/newcurrentInstantaneousNew requests hit new codeIn-flight requests finishon old code safely⚠ OPcache still holdsOLD bytecode!Step 3: Invalidatesystemctl reloadphp8.4-fpmGraceful RestartWorkers respawn withfresh OPcacheNo dropped connections✓ Fully LiveRollbackdep rollbackRe-points symlinkto previous release+ FPM reload< 5 secondsto recover
Atomic symlink swap followed by PHP-FPM reload ensures zero-downtime Symfony deployment with instant rollback capability

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.

Frequently Asked Questions

Symfony 7 requires PHP 8.2 or higher, at least 1GB RAM, and a web server like Nginx or Apache with PHP-FPM.

Local providers charge Rs 1,500–3,000 monthly (~USD 11–22) for 2GB RAM; international options start around USD 6/month.

Install PHP 8.3 stable; it offers the best balance of performance, security patches, and package compatibility for current Symfony releases.

In my experience deploying Symfony apps, Nginx handles static assets faster and consumes less memory under load than Apache. Its configuration model maps cleanly to Symfony’s front-controller pattern, avoiding .htaccess overhead. For high-traffic legal-tech portals I have built, Nginx with PHP-FPM consistently delivers lower latency and simpler virtual host management compared to Apache modules, making it my default choice for production Ubuntu servers running modern PHP frameworks.

Create a dedicated pool file in /etc/php/8.3/fpm/pool.d/ with a unique socket name. Set pm = dynamic with pm.max_children calculated as available RAM divided by 50MB per process. Enable opcache.validate_timestamps=0 in production to prevent unnecessary file checks. Configure slowlog and request_terminate_timeout to catch hanging requests early. On client projects, I always isolate Symfony pools from other sites to prevent resource contention during traffic spikes or background job processing.

The var/cache, var/log, and var/sessions directories must be writable by the PHP-FPM user, typically www-data. Run chown -R www-data:www-data var/ after every deployment. Avoid chmod 777; instead use ACLs or setfacl to grant write access only where needed. Incorrect permissions cause silent cache failures or 500 errors that appear only after clearing cache. I have debugged this repeatedly on Ubuntu deployments where deploy scripts reset ownership incorrectly during zero-downtime releases.

Store secrets in /var/www/project/.env.local outside the web root and exclude it from version control. Never put credentials in .env committed to Git. Use Symfony’s dotenv component to load .env.local automatically in production. Restrict file permissions to 600 owned by www-data. For multi-environment setups, I use Deployer to upload environment-specific files during release rather than storing them on the server permanently, reducing exposure if the server is compromised.

No. Run composer install --no-dev --optimize-autoloader locally or in CI, then deploy vendor/ as part of the release artifact. Installing on production wastes time, risks dependency resolution failures, and requires dev tools you should not keep on live servers. On projects using Deployer 7, I build dependencies in GitLab CI and transfer the complete release directory via rsync. This ensures identical artifacts across staging and production while keeping the VPS lean and secure.

Execute php bin/console cache:warmup as the www-data user after symlink swap but before switching traffic. Include it in your Deployer task sequence after vendor install and before php-fpm reload. Warming prevents first-request latency spikes that degrade Core Web Vitals. On legal-tech portals handling document generation, cold caches caused timeout errors under load until we added explicit warmup steps. Always verify cache directory ownership matches the FPM user to avoid permission denied errors during this critical step.

Use MySQL 8.0 or MariaDB 10.11 with InnoDB buffer pool sized to 70% of available RAM. Enable query_cache_type=0 since MySQL 8 removed it. Configure PDO persistent connections in doctrine.yaml to reduce connection overhead. Place the database on the same VPS for low-latency communication unless scaling demands separation. On eCommerce projects, I tune innodb_log_file_size and max_connections based on actual query patterns rather than generic benchmarks, preventing lock contention during checkout flows.

Install Certbot and run certbot --nginx -d yourdomain.com to obtain and auto-renew Let's Encrypt certificates. Configure HSTS headers in Nginx after verification succeeds. Test renewal with certbot renew --dry-run monthly. On sister sites sharing infrastructure, I centralize certificate management through automated Deployer hooks that trigger renewal checks during routine maintenance windows. Always redirect HTTP to HTTPS at the Nginx level, not in Symfony, to avoid unnecessary PHP processing and ensure secure transport before application code executes.

Missing try_files directive in Nginx causes this. Ensure location / includes try_files $uri /index.php$is_args$args so all non-static requests route through public/index.php. Verify DOCUMENT_ROOT points to public/, not project root. Check that PHP-FPM socket path matches upstream configuration. After fixing, restart both nginx and php8.3-fpm services. I have seen this break silently when copying configs between servers because subtle whitespace or variable differences prevent proper fallback routing to Symfony’s front controller.

Configure Nginx access/error logs with structured JSON format for parsing. Set up logrotate to prevent disk exhaustion. Use monit or systemd watchdog to auto-restart PHP-FPM on failure. Expose /health endpoint returning 200 only when database and cache are reachable. On production systems, I add cron jobs checking disk space, memory usage, and failed queue workers every five minutes, alerting via SMS gateway when thresholds breach. Proactive monitoring catches issues before users report them, especially during peak business hours.

Yes, using separate PHP-FPM pools and Nginx virtual hosts per application. Allocate distinct sockets, log paths, and environment files. Monitor total memory consumption across all pools to avoid OOM kills. On shared EC2 infrastructure hosting legal-tech sister sites, I run four Symfony applications on a single 4GB VPS by carefully tuning max_children and enabling opcache.file_cache for faster restarts. Isolate deployments so updating one app never affects others’ runtime state or cached configurations.

Running console commands as root creates cache files owned by root, causing permission errors when www-data tries to overwrite them. Skipping opcache invalidation after deploy serves stale bytecode. Forgetting to set APP_ENV=prod enables debug mode exposing sensitive data. Not configuring timezone in php.ini causes date mismatches in legal documents. Always validate deployment scripts on staging first. In fifteen years of production work, these four issues account for most post-deploy incidents I troubleshoot on client Symfony installations.

Share this article

Quick Contact Options
Choose how you want to connect me: