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_event and mod_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 -f before loading — you are editing the wall you're standing behind.
  • Fail2ban with the sshd and Apache jails, plus the recidive jail 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_db via 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.