A Symfony deploy is the same short list every time: production mode on, the right environment variables in place, dependencies installed without the dev packages, a warm cache, a migrated database, a writable var/ directory, and a decision about what runs your Messenger workers. FrankenPHP does not change that list. It changes the server underneath it: instead of nginx talking to PHP-FPM, one process, built on Caddy, serves HTTP and runs PHP. For a Symfony application that is mostly good news, and this guide covers the parts where it is not.
It is written for the Laravel / PHP app line on RE:NODE, which runs Laravel or Symfony on FrankenPHP with PHP 8.3, 8.4 or 8.5 and Composer installing dependencies on start. The Symfony half of every command below is the same on any host. The platform half - where the variables go, how the proxy slot reaches you, what the panel gives you - is marked as such, and anything the platform does not document is called out rather than guessed.
What changes with FrankenPHP#
A classic PHP stack has three moving parts: a web server (nginx or Apache), PHP-FPM, and the glue between them. FrankenPHP collapses that into a single binary. It embeds the PHP interpreter in a Go web server built on Caddy, so there is one process to start, one log to read, and no fastcgi_pass line to get wrong.
That has three practical consequences for a Symfony app.
- There is no nginx config to port. The
try_filesandfastcgi_split_path_infoblocks from the Symfony docs exist to route every request topublic/index.php. Caddy has a front-controller idiom that does the same job, and a Symfony project served by FrankenPHP does not need your old server block. - Worker mode exists, and is optional. In normal mode FrankenPHP behaves like PHP-FPM: each request boots the framework, handles the request and throws everything away. In worker mode the Symfony kernel boots once and stays in memory across requests, which can cut per-request time a lot. It needs the
runtime/frankenphp-symfonypackage and carries the same warnings as any long-lived PHP process: static state and service state leak between requests unless the services are reset. Symfony resets most of its own, but your code is your problem. Treat worker mode as a tuning step after the app works, not a prerequisite for deploying it. - PHP version is a platform setting. RE:NODE offers 8.3, 8.4 and 8.5 on this line. Symfony 7.4 needs PHP 8.2 or newer, and Symfony 8.0 needs PHP 8.4, so check the
require.phpconstraint and thesymfony/*versions in yourcomposer.jsonbefore choosing. Take the newest PHP your whole dependency tree allows.
Everything else in this guide is ordinary Symfony. If you are weighing how much of it carries over from Laravel, deploying Laravel to production covers the framework side of that stack, and the two posts differ mostly in names: APP_ENV=prod versus production, bin/console versus artisan, Messenger versus the queue worker.
Production mode: APP_ENV, APP_DEBUG and APP_SECRET#
Symfony decides almost everything from three variables. In production they are:
APP_ENV=prodAPP_DEBUG=0APP_SECRET=replace-with-a-long-random-stringAPP_ENV=prod selects the prod configuration and the when@prod blocks in config/. It also stops the framework recompiling the container and checking template modification times on every request. APP_DEBUG=0 turns off the debug container and the exception page that prints file paths, service arguments and environment details. Running APP_ENV=prod with APP_DEBUG=1 is possible and worth avoiding: you get production caching with development leakage.
APP_SECRET feeds CSRF tokens, signed URLs, the remember-me cookie and anything else Symfony signs. Generate it once and leave it alone, because changing it invalidates signed links and logs people out. A reasonable way to make one:
$ php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'The default environment, when nothing sets one, comes from the APP_ENV line in .env, and the recipe ships it as dev. That is the first thing to check when a freshly deployed app shows the Symfony profiler toolbar to the whole internet: no real variable overrode the file.
.env, .env.local and real environment variables#
Symfony's Dotenv component loads files in a defined order, and understanding it removes most "why is it using the wrong database" tickets. The files, from lowest to highest priority:
| File | Committed? | Purpose |
|---|---|---|
.env | Yes | Defaults and documentation of every variable |
.env.local | No | Machine-specific overrides, not read when APP_ENV=test |
.env.prod | Yes | Defaults for the prod environment |
.env.prod.local | No | Machine-specific prod overrides |
Above all of those sit real environment variables: values set by the process that starts PHP. Dotenv never overwrites a variable that already exists, so a real variable always wins over any file. That is the property you want in production. The files are a convenience for development; the server's own environment is the source of truth.
What follows from that:
- Commit
.envwith safe placeholders only. It documents the variable names and is the baseline. - Do not put production secrets in
.env, and never commit.env.local. Anything that reaches git has to be treated as leaked. - On RE:NODE, set the production values as environment variables on the Startup tab of the server. They are real variables, so they override the files. Environment variables and secrets goes through what belongs where and what not to log.
- If you prefer a file, create
.env.localon the server through the file manager or SFTP. It works, and it sits in the project directory, so exclude it from any archive you share.
There are two Symfony-native alternatives worth knowing. The first is composer dump-env prod, which compiles the .env files into a single .env.local.php array that is loaded without parsing text on each boot. Run it on the machine that will serve the app, as part of the deploy, and remember it freezes the values: change a variable and you must dump again. The second is the secrets vault, php bin/console secrets:set DATABASE_PASSWORD --env=prod, which stores encrypted values in config/secrets/prod/ that can be committed. Only one real variable is then needed on the server - the decryption key, SYMFONY_DECRYPTION_SECRET - and php bin/console secrets:decrypt-to-local --env=prod can turn the vault into a local file when you want one.
Composer on start and the production install#
On this app line Composer installs dependencies when the server starts, so a new composer.lock takes effect on the next restart. The Symfony-side rule is the same as anywhere: commit composer.lock, so the install resolves the exact versions you tested. Without a lock file, every start re-resolves the constraints and the version you run in production is whatever Packagist offered that morning.
The command a production install should be equivalent to is:
$ composer install --no-dev --optimize-autoloader --no-interaction--no-dev skips require-dev packages: the profiler, the maker bundle, PHPUnit, debug tooling. --optimize-autoloader builds a class map so PHP does not search the filesystem on each autoload. In Symfony apps, composer.json also carries an auto-scripts section, which the recipe wires into post-install-cmd so that cache:clear and assets:install public run after every install. That is why the install and the cache step are entangled.
The classic failure follows from it. If APP_ENV is not prod when Composer finishes and dev packages are absent, the post-install cache:clear tries to build the dev container, finds Symfony\Bundle\DebugBundle\DebugBundle missing from config/bundles.php's dev entries, and aborts. Set APP_ENV=prod in the environment before the install runs, not after, and the error does not appear. If you need dev packages on a staging server, keep them in place and set APP_ENV=dev there on purpose.
The platform does not document which flags the start-up install uses, so read the console output of a first start rather than assuming. The console shows the install output live, unfiltered. Reading the console explains the history, the folded noise and the memory graph, which you will want open the first time.
Front-end assets are the other build step. With AssetMapper, php bin/console asset-map:compile writes the fingerprinted files to public/assets/. With Webpack Encore or Vite, build on your own machine or in CI and upload the output: installing Node and compiling a large bundle on a 1 GB plan is a sensible way to hit the memory limit.
Cache clear and warmup#
The production cache lives in var/cache/prod/. It contains the compiled service container, the routing table, compiled Twig templates, validator and serializer metadata, and the Doctrine proxy classes. All of it is generated, none of it belongs in git, and a stale copy is the cause of a surprising share of "I deployed but nothing changed" reports.
Two commands matter:
$ php bin/console cache:clear$ php bin/console cache:warmupIn the prod environment cache:clear rebuilds the cache and warms it as part of the same run, so one command is normally enough; cache:warmup exists to warm without clearing, and --no-warmup on cache:clear skips the warm-up. The reason to warm in the deploy rather than leave it to the first request is simple: the first request after a cold cache compiles the container and can take many seconds and several hundred megabytes of memory. On a small plan that spike can reach the limit by itself.
Two habits keep this uneventful:
- Build the cache where it will be served. The compiled container contains absolute paths. A cache built on your laptop and uploaded points at directories that do not exist on the server.
- Do not mix users. If a command run as one user creates
var/cache/prod/and the web process runs as another, the second cannot write to it. The panel console and the server process share the container's user, which avoids the problem; an SFTP session as a different user can recreate it, so clear the cache from the console after uploading.
Also be aware that cached configuration is the reason a changed environment variable seems to do nothing. Symfony resolves %env()% values at runtime, so ordinary variables are not baked into the cache, but anything compiled from the container, like a parameter you derived in services.yaml, is. When in doubt, clear.
Doctrine migrations#
Schema changes ship as migration classes in migrations/, and the production command is:
$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration--no-interaction stops the confirmation prompt that would hang a non-interactive deploy. --allow-no-migration makes the command exit successfully when there is nothing to run, which keeps a deploy script from failing on the common case of a release without schema changes. Before the first deploy, and any time you are unsure, doctrine:migrations:status prints the configured database, the current version and how many migrations are pending, and doctrine:schema:validate compares your mapping against the live schema.
Never use doctrine:schema:update --force in production. It diffs and applies in one step, with nothing reviewed and nothing recorded. Generate migrations in development with php bin/console doctrine:migrations:diff, read the SQL, commit it, and let production only run committed migrations.
The DATABASE_URL variable carries the connection. The scheme follows the engine and the host, port, user and password come from wherever the database was created:
DATABASE_URL="postgresql://app:secret@db.example.net:5432/app?serverVersion=16&charset=utf8"For MySQL the scheme is mysql:// and the serverVersion is the real server version, not a guess: Doctrine uses it to pick SQL dialect features, and a wrong value produces migrations that fail in odd ways. On RE:NODE an app server has database slots created in the panel with a generated host, user and password, and the separate database line sells PostgreSQL, MySQL 8.4 LTS and others on their own host and port. Take the values from the panel, and URL-encode the password if it contains characters like @, / or :, otherwise Doctrine parses the URL into nonsense. Database backups and restores explains the backup half of the story.
Order matters. A release that ships code needing a new column before the migration has run gives every visitor a 500 for the length of the gap. For anything beyond a small site, write backward-compatible migrations: add the column nullable, deploy code that tolerates it, backfill, then tighten in a later release. Migrations without downtime goes through the pattern. Take a backup before any migration that drops or renames something; on RE:NODE the Backups tab does that on demand and restores with a button.
Messenger workers, the transport and the scheduler#
Anything slow in a request - mail, image processing, calls to third parties - belongs on a Messenger transport, and a transport needs a consumer process that outlives the request. First choose the transport in .env:
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0The Doctrine transport stores messages in a database table and needs nothing extra. Set auto_setup=0 in production and create the table once with php bin/console messenger:setup-transports, so the app never tries to alter the schema at runtime. For more throughput, a Valkey-backed transport (redis:// in the DSN, since Valkey speaks the Redis protocol) is the next step - see job queues with Valkey.
The consumer is a long-running command:
$ php bin/console messenger:consume async --time-limit=3600 --memory-limit=128MThe two limits are the important part. --time-limit=3600 makes the worker exit after an hour, and --memory-limit=128M makes it exit when it grows past that, which defends you against a slow leak in code you do not control. A supervisor is expected to start it again, which is the part a container deployment has to answer for itself.
A RE:NODE server is one container running one process. The panel's tabs are built around that, so the honest design is to treat the consumer as its own server: a second app server on the same line, same repository, same environment variables, same database, started with a command that runs the worker instead of the web server. Check the Startup tab on your plan for whether the start command can be changed, because the platform copy confirms environment variables there and does not describe command editing. If it cannot, the alternatives are a messenger transport that is consumed by a small scheduled console task, or running with the sync transport for low volume while you decide. Do not run a worker as a background process inside the web server's own console session and expect it to survive a restart.
After every deploy the consumer holds the old code in memory. Tell it to stop with php bin/console messenger:stop-workers, which sets a flag in the cache pool; each worker finishes its current message and exits, and the restart brings it back on the new code. Failed messages go to a failure transport if you configure one, and then:
$ php bin/console messenger:failed:show$ php bin/console messenger:failed:retryThe same long-running machinery runs the Symfony Scheduler: define the recurring messages with #[AsPeriodicTask] or a schedule provider, and consume the scheduler_default transport with messenger:consume scheduler_default. It replaces a crontab of console commands with one worker. If you would rather use plain cron expressions, cron expressions explained covers the five fields, and background jobs on a small server compares the options for the size of machine this guide assumes.
var/ must be writable, and where logs go#
Symfony writes to exactly one tree at runtime: var/. Within it, var/cache/ holds the compiled cache and var/log/ holds log files, and sessions land in var/sessions/ if you use the file handler. The web process must be able to create and write files there. Nothing else in the project needs write access, including src/, config/ and vendor/.
The symptom of a non-writable var/ is unmistakable once you have seen it: a 500 error and, in the console or var/log, a message beginning Unable to create the "cache" directory or Failed to write file. The cure is to fix ownership, not to loosen permissions to 777:
$ chmod -R ug+rwX varLogging deserves a decision. The recipe's Monolog configuration for prod buffers records with a fingers_crossed handler, which only writes the whole request's context once an error-level record arrives, and sends the result to php://stderr in JSON. On a container that is the right default, because stderr goes straight to the console where you can read it live, and nothing fills the disk. If you switch the handler to a stream under var/log/prod.log for long retention, rotate it: Monolog's rotating_file handler with a max_files value stops a log from growing until the plan's disk is full. Logs worth keeping discusses what to keep and for how long.
Uploads are a separate matter. Files users upload should live somewhere you can back up, not in var/, which is disposable by design. Put them on a path outside the cache tree, or in S3-compatible object storage, and include the path in your backup plan. A backup nobody has restored is a hypothesis.
Behind the proxy slot: trusted proxies and X-Forwarded-For#
Almost every production Symfony app sits behind something that terminates TLS. On RE:NODE, app plans include a proxy slot: point an A record at the address the panel shows, and the certificate is issued and renewed for you, inside a 21-day window. The real client IP arrives in the X-Forwarded-For header. What a reverse proxy does explains the path, and HTTPS and Let's Encrypt explained covers why issuance fails when DNS is not ready.
The consequence for Symfony is that by default it does not believe forwarded headers. Without configuration, $request->getClientIp() returns the proxy's address, $request->isSecure() is false, and UrlGeneratorInterface builds http:// links on an HTTPS page, which browsers block as mixed content. Rate limiters and login throttling then count every visitor as one client, which is worse than it sounds.
Tell Symfony which proxies to trust and which headers to read, in config/packages/framework.yaml:
framework: trusted_proxies: '%env(TRUSTED_PROXIES)%' trusted_headers: - 'x-forwarded-for' - 'x-forwarded-host' - 'x-forwarded-proto' - 'x-forwarded-port' - 'x-forwarded-prefix'Then set the variable. Symfony accepts a comma-separated list of IPs and CIDR ranges, plus two special values: PRIVATE_SUBNETS trusts the loopback and private ranges, and REMOTE_ADDR trusts whatever address the connection arrives from.
TRUSTED_PROXIES=PRIVATE_SUBNETSThe trade-off is the important part. REMOTE_ADDR is correct only when the proxy is the single way into the application, because anything allowed to connect can then forge X-Forwarded-For and pretend to be anyone. The platform copy does not state the proxy's own address, so the reliable method is empirical: deploy a route that prints $request->server->get('REMOTE_ADDR') while logged in as you, read the value, and put that address or range in TRUSTED_PROXIES instead of a wildcard. Remember that behind a trusted proxy the header can contain a chain of addresses, and Symfony picks the last untrusted one, which is why the list has to be right and not just broad.
Set DEFAULT_URI or the router's default_uri too (the older spelling is the router.request_context.host and scheme parameters) so that URLs generated in console commands and Messenger handlers, which have no request to read a scheme from, use https:// and your real hostname.
A health route and what to watch#
Symfony has no built-in health endpoint, which is easy to fix and worth fixing. A good one returns quickly, does not touch anything slow, and optionally checks the database separately:
<?phpnamespace App\Controller;use Doctrine\DBAL\Connection;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Routing\Attribute\Route;final class HealthController{ #[Route('/healthz', name: 'health', methods: ['GET'])] public function live(): JsonResponse { return new JsonResponse(['status' => 'ok']); } #[Route('/healthz/db', name: 'health_db', methods: ['GET'])] public function db(Connection $connection): JsonResponse { $connection->executeQuery('SELECT 1'); return new JsonResponse(['status' => 'ok']); }}Keep the liveness route free of database calls, so a database blip does not make a monitor conclude that the web process is dead. Make sure your firewall configuration leaves /healthz public (for example ^/healthz under access_control with PUBLIC_ACCESS), and that it is not caught by a locale prefix. Graceful shutdown and health checks goes through what to probe and why.
The panel's own view matters just as much. The console graph shows memory, CPU and disk against the plan limits, and RE:NODE has two behaviours worth knowing for a PHP service. At the memory limit the kernel stops the container and it restarts clean instead of swapping, so a leak or an oversized cache warm-up shows up as a restart with nothing in the application log. And a watcher polls every two minutes for uptime that went backwards: three restarts in an hour raise a warning on the server page and an automatic ticket, and six lead to suspension. A crash loop on a bad deploy, such as a missing migration, is therefore something to stop and fix promptly rather than watch.
Size the plan by measurement, not by hope. A Symfony app in prod mode without worker mode commonly uses tens of megabytes per request, and a cold-cache first request uses several times that; sizing a web app for launch day turns that into a method, and PHP settings that matter covers memory_limit and OPcache.
Deploying: git, SFTP and the first-start page#
There are two ways code reaches the server, and both are on the panel.
- Git deploy. GitHub only, through a GitHub App with short-lived tokens, so private repositories work. Two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. Every deploy gets its own record. Because Composer installs on start, this combination gives you "push, restart, new dependencies" without a build server.
- File manager or SFTP. SFTP credentials are per server, not per account. The file manager unpacks archives in place, which suits uploading a zipped release. Do not upload
vendor/,var/or any.env.localfrom your machine.
A new server on this line starts with a small working page on its address until you deploy code, and that page never overwrites files you have uploaded, so the first sight of the address is the placeholder, not your application. Once the code is there, a deploy sequence that you can run the same way every time looks like this:
$ composer install --no-dev --optimize-autoloader --no-interaction$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration$ php bin/console cache:clear$ php bin/console messenger:stop-workersRun it from the panel console where you can, and keep each step in the same order every time: install, migrate, rebuild the cache, restart workers. Take a backup first when a migration drops anything. For a deploy that is invisible to visitors rather than merely quick, zero-downtime deploys on a small server covers the release-directory pattern, with the caveat that it needs more disk and more care on a plan with one container.
Troubleshooting#
A blank page, or a bare 500, in production. That is APP_DEBUG=0 doing its job. Read the console output first, since Monolog sends errors to stderr, then var/log/prod.log if you changed the handler. If both are empty, PHP could not write them: check var/ permissions.
The profiler toolbar appears, or the exception page shows file paths. APP_ENV or APP_DEBUG is still the development value. Set APP_ENV=prod and APP_DEBUG=0 as real variables, and check that no stray .env.local.php is overriding them.
"Unable to create the cache directory" or "Failed to write file". var/ is not writable for the process that runs PHP, or an earlier command created var/cache under a different user. Fix ownership, then run cache:clear from the console.
"Class DebugBundle not found" after a deploy. Dev packages were removed with --no-dev, but the container was built in the dev environment. Set APP_ENV=prod before Composer runs.
Everything works except links, which are http:// and redirect loops on login. Trusted proxies are not configured, so Symfony cannot see HTTPS. Set TRUSTED_PROXIES and the trusted_headers list shown above.
A change to an environment variable does nothing. A real variable needs a restart of the server to reach PHP. If you also use composer dump-env, the compiled .env.local.php still holds the old value. Dump again or delete it.
"An exception occurred in the driver: could not translate host name". The DATABASE_URL host is wrong or the password has unencoded special characters. Copy the host and port from the panel, and encode the password.
Messages sit in the queue and nothing consumes them. No consumer is running, or it is running the old code. Start one, and make messenger:stop-workers part of every deploy so a long-lived worker is replaced.
The server restarts on its own, with nothing in the log. The memory limit. Look at the memory graph around the restart time. Lower --memory-limit on the worker, warm the cache in the deploy rather than on the first request, and read Linux swap and the OOM killer for what a kernel stop means.
FAQ#
Do I need worker mode to run Symfony on FrankenPHP?
No. FrankenPHP serves Symfony in its ordinary mode, one boot per request, exactly as PHP-FPM would. Worker mode is a performance option that keeps the kernel in memory between requests. Switch it on after the application is stable, and test for state that leaks from one request into the next.
Where should production secrets live: .env or real environment variables?
In real environment variables or the Symfony secrets vault. .env is committed and documents the names. A real variable always wins over any file because Dotenv does not overwrite existing values. On RE:NODE the Startup tab is where environment variables go.
Is composer install --no-dev safe for a Symfony app?
Yes, as long as nothing in production code relies on a dev package and APP_ENV=prod is set before the install. If a "class not found" error names a package, it belongs in require, not require-dev. Maker, the profiler and PHPUnit should stay in require-dev.
Why does Symfony see the proxy's IP instead of the visitor's?
Because it distrusts forwarded headers until told otherwise. Set framework.trusted_proxies to the proxy's address or range, list the x-forwarded-* headers under trusted_headers, and getClientIp() returns the address from X-Forwarded-For. Avoid trusting everything if your origin is reachable directly.
Do Doctrine migrations run automatically on deploy?
Not by themselves. You must run doctrine:migrations:migrate as a step, and make it non-interactive with --no-interaction. Run it before the new code serves traffic where the change is not backward-compatible, and take a backup before destructive ones.
Can the Messenger worker run in the same server as the website?
A server is one container with one process, so plan the worker as a second server using the same code, variables and database. Whether a plan's start command can be changed is something to confirm on its Startup tab before you commit to that layout.


