Deploying Symfony on a VPS in 2026: Apache, PHP-FPM and systemd
The complete production recipe for a Symfony SaaS on a plain Linux box — a dedicated system user, a tuned PHP-FPM pool, OPcache with timestamp checks off, the Messenger worker everyone forgets, TLS, hardening, backups, and a deploy routine that doesn't drift. From a server running real products.
Deployment advice for Symfony in 2026 comes in two flavors: "just use a PaaS" and a wall of Kubernetes YAML. Both skip the option that quietly runs an enormous share of production PHP: one Linux box with Apache or nginx, PHP-FPM, PostgreSQL and systemd. Boring, cheap, debuggable with tools you already know — and more than enough to carry a SaaS from first signup well past the point where it pays a salary.
The catch is that nobody hands you the whole recipe. Tutorials stop at
"it serves a page", and the parts that actually bite in production — the
OPcache flag that makes deploys appear to do nothing, the async worker
whose absence silently eats every email, the one sudo rule your CI
needs — live in scar tissue, not docs. This post is the complete recipe
we run in production, condensed from a live server. Steal all of it.
The stack, with the support math
Pin your platform the way you pin dependencies. Support windows, checked 6 July 2026:
- PHP 8.4 — active support to December 2026, security fixes to December 2028 (php.net). Everything below works identically on 8.5; adjust the version in paths.
- Symfony 7.4 LTS — bug fixes to November 2028, security fixes to November 2029. The support math has its own post; the short version is that the LTS buys a small team three quiet years.
- PostgreSQL 16+, Debian 13 (or Ubuntu LTS), Apache 2.4 with
mpm_eventandmod_proxy_fcgi— or nginx; the recipe is the same shape, and I'll note the equivalents where they differ.
One apt line covers the fleet:
apt install php8.4-fpm php8.4-pgsql php8.4-intl php8.4-gd php8.4-curl \
php8.4-xml php8.4-mbstring php8.4-opcache php8.4-apcu \
postgresql apache2 certbot python3-certbot-apache git unzip
Plus Composer. That's the entire platform — if you're on the no-Node asset pipeline (AssetMapper + Tailwind's standalone binary), there is nothing else to install. Ever.
One Unix user per app
The single highest-leverage isolation decision on a shared box: every
app gets its own system user, and PHP-FPM runs that app's code as that
user. If one site is ever compromised, the blast radius is that site's
files — not every .env.local on the machine.
adduser --system --no-create-home --group --shell /usr/sbin/nologin myapp
mkdir -p /var/www/myapp && chown myapp:myapp /var/www/myapp
Same logic for the database — one PostgreSQL role and one database per app, listening on localhost only (Debian's default):
sudo -u postgres psql <<SQL
CREATE USER myapp WITH PASSWORD '<strong password>';
CREATE DATABASE myapp_db OWNER myapp ENCODING 'UTF8' TEMPLATE template0;
SQL
Build the release
As a deploy user with write access to /var/www/myapp (not root, not
the FPM user):
cd /var/www/myapp
git clone <your-repo> . # or rsync a release
composer install --no-dev --optimize-autoloader --no-interaction
$EDITOR .env.local # APP_SECRET, DATABASE_URL, MAILER_DSN…
composer dump-env prod # compiles env vars to PHP — no runtime parsing
php bin/console assets:install public # bundle assets
php bin/console importmap:install # JS vendors, pinned versions
php bin/console tailwind:build --minify # CSS via the standalone binary
php bin/console asset-map:compile # fingerprinted files in public/
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:warmup
Two details that save future debugging:
chmod 640 .env.local, group = the app's user. Secrets stay readable by exactly the code that needs them.- The FPM user writes only
var/. The code stays owned by the deploy user; grant the runtime just the cache and log directories:
setfacl -R -m u:myapp:rwX var
setfacl -dR -m u:myapp:rwX var
A web user that can't rewrite its own code is a whole class of exploits downgraded to a bad day.
A PHP-FPM pool of your own
One pool per app, so each site gets its own process tree, its own
socket, its own limits — /etc/php/8.4/fpm/pool.d/myapp.conf:
[myapp]
user = myapp
group = myapp
listen = /run/php/php8.4-fpm-myapp.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 = 6
pm.max_requests = 500
Size pm.max_children from memory: a warmed-up Symfony worker sits
around 60–80 MB, so 20 children fit comfortably in ~2 GB with room for
PostgreSQL. Watch systemctl status php8.4-fpm for "max_children
reached" warnings before touching anything else.
Then the single biggest free performance win on the whole page — in production, stop OPcache from re-checking files on every request:
opcache.validate_timestamps = 0
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
The consequence is a rule you must tattoo into your deploy script: new code is not live until PHP-FPM reloads. Forget it once and you'll spend an hour debugging a fix that is on disk but not in memory.
php-fpm8.4 -t && systemctl reload php8.4-fpm
The vhost
Apache, /etc/apache2/sites-available/mydomain.tld.conf:
<VirtualHost *:80>
ServerName mydomain.tld
DocumentRoot /var/www/myapp/public
<Directory /var/www/myapp/public>
AllowOverride None
Require all granted
FallbackResource /index.php
Options -Indexes +FollowSymLinks
</Directory>
# Versioned assets: serve files directly, never fall back to PHP
<Directory /var/www/myapp/public/bundles>
FallbackResource disabled
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php8.4-fpm-myapp.sock|fcgi://localhost"
</FilesMatch>
SetEnv APP_ENV prod
SetEnv APP_DEBUG 0
</VirtualHost>
a2enmod proxy_fcgi http2 headers
a2ensite mydomain.tld && apachectl configtest && systemctl reload apache2
FallbackResource replaces the old mod_rewrite dance, and because
asset-map:compile wrote real fingerprinted files into public/,
assets are served as plain static files with far-future cache headers —
PHP never sees them. nginx users: try_files $uri /index.php$is_args$args;
plus the standard fastcgi_pass block against the same socket.
TLS is a one-liner and a systemd timer you never think about again:
certbot --apache -d mydomain.tld -d www.mydomain.tld --redirect
systemctl list-timers certbot.timer # renewal is already scheduled
The worker everyone forgets
Here's the failure mode that generates the support tickets: the site works, signups work, checkout works — and no email ever arrives, and subscriptions never sync. Nothing is "down". You just deployed an app whose emails and webhooks are processed asynchronously without starting anything to process them.
A Messenger worker is a systemd service —
/etc/systemd/system/myapp-messenger.service:
[Unit]
Description=MyApp Messenger worker
After=network.target postgresql.service
[Service]
User=myapp
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now myapp-messenger
journalctl -u myapp-messenger -f # watch it work
--time-limit=3600 makes the worker exit cleanly every hour — memory
hygiene — and Restart=always brings it straight back. Messages that
fail repeatedly park on the failed transport; check
bin/console messenger:failed:show when something looks off, :retry
when the cause is fixed.
Deploys that don't drift
Every deploy is the same seven lines. Put them in a script or a CI job on day one — the whole value is that they never change and never get half-remembered at 23:00:
cd /var/www/myapp
git pull # or rsync the new release
composer install --no-dev --optimize-autoloader --no-interaction
php bin/console importmap:install
php bin/console tailwind:build --minify
php bin/console asset-map:compile
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear
sudo systemctl reload php8.4-fpm # REQUIRED — OPcache (see above)
sudo systemctl restart myapp-messenger # workers must load the new code
The two sudo lines are the ones people forget: the reload because of
validate_timestamps = 0, the worker restart because a long-running
process happily keeps executing last week's code. If CI runs the deploy,
give the deploy user passwordless sudo for exactly those two commands
and nothing else — /etc/sudoers.d/myapp-deploy:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.4-fpm, /usr/bin/systemctl restart myapp-messenger
That's the entire privilege escalation surface of your pipeline.
An afternoon of hardening
Not a security audit — the 20 % that removes 95 % of the noise:
- Firewall: default-drop inbound. With nftables (or ufw), allow SSH
(rate-limited — 60 new connections/minute stops brute force cold),
HTTP, HTTPS, essential ICMP; drop the rest. Validate with
nft -c -fbefore loading — you are editing the wall you're standing behind. - Fail2ban with the sshd and Apache jails, plus the
recidivejail for progressive bans. It won't stop a targeted attacker; it turns the log noise of the permanent internet-wide scan down to zero. - Backups you have restored. A nightly
pg_dump -Fc myapp_dbvia a systemd timer, compressed, 14-day retention, copied off the box. Then actually restore one into a scratch database once — an untested backup is a hope, not a backup. - Deliverability DNS before the first real email: SPF, your provider's DKIM record, and a DMARC policy. Aim for 10/10 on mail-tester.com — transactional email that lands in spam is indistinguishable from the worker not running.
The five-minute smoke test
After the first deploy — and the deploy script's curl can keep doing
the first half forever:
curl -I https://mydomain.tld/ # 200
curl -I https://mydomain.tld/login # 200
curl -I https://mydomain.tld/robots.txt # 200
Then, in a browser: register a real account — the verification email arriving proves the worker and the DNS records in one shot. Run a test-mode checkout — the subscription updating proves the webhook path. If both pass, the boring stack is doing its job and you can go back to writing features.
When Docker is the better answer
Honest boundary: if your team already lives in containers, or you want
one artifact promoted across environments, skip the pool-and-vhost work
and run the FrankenPHP production image —
web server and PHP in a single container, automatic TLS, migrations at
boot, the worker as a second container from the same image. You still
own backups, monitoring and the firewall; Docker relocates the recipe,
it doesn't delete it. What it costs you is the transparency this whole
post is built on: when something is weird at 2 a.m., journalctl,
systemctl status and a socket you can point curl at are hard to
beat.
Steal the recipe
Nothing above is exotic — it's an afternoon the first time and a
git pull forever after. The stack's superpower is precisely that it
has no moving parts you didn't put there.
If you'd rather start from a codebase where this is already wired — these exact configs as ready-to-copy reference files, the deploy pipeline as a GitHub Actions workflow with the narrow-sudo pattern, and underneath it a Symfony 7.4 SaaS with auth, 2FA, teams, billing on two providers, an admin and 327 tests — that's what ShipAnvil ships. The live demo runs on a box configured exactly like this page.