Symfony is one of the most mature PHP frameworks, but deploying it cleanly to production still trips people up: the var/cache permissions, the APP_ENV=prod dance, running Doctrine migrations safely, and wiring a database without leaking credentials into your repo. This guide walks through deploying a Symfony app with a managed PostgreSQL database on a container platform.
What Symfony needs in production
A production Symfony deployment has a few non-negotiables:
APP_ENV=prodso the framework uses the optimized container and skips debug tooling.- A warmed cache (
bin/console cache:clear/cache:warmup) built at image time, not at request time. - Composer dependencies installed with
--no-dev --optimize-autoloader. - A real web server (
php-fpmbehind nginx, orFrankenPHPfor a single-binary setup). - A database connection string that is injected, never committed.
Symfony reads configuration from environment variables via .env files, but in production you should override those with real env vars. The most important one is DATABASE_URL, which Doctrine consumes directly.
Project structure recap
A typical Symfony 7 app looks like this:
my-symfony-app/
├── bin/console
├── config/
├── migrations/
├── public/index.php
├── src/
├── composer.json
└── .envThe web root is public/, and every request is routed through public/index.php.
Writing a production Dockerfile
FrankenPHP makes Symfony deployment dramatically simpler because it bundles the PHP runtime and a production web server in one image. Here's a multi-stage Dockerfile:
# syntax=docker/dockerfile:1
FROM dunglas/frankenphp:1-php8.3 AS base
RUN install-php-extensions \
pdo_pgsql \
intl \
opcache \
zip
WORKDIR /app
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install dependencies first (better layer caching)
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy source and finish autoload
COPY . .
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
ENV APP_ENV=prod
RUN php bin/console cache:clear --no-debug \
&& php bin/console cache:warmup --no-debug
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]Note the ordering: copying composer.json/composer.lock *before* the rest of the source means dependency installation is cached unless your lockfile changes.
Provisioning a managed PostgreSQL database
Rather than running your own Postgres, use a managed database. On PandaStack you create a PostgreSQL 16 instance from the dashboard, and when you link it to your app the platform injects a DATABASE_URL connection string automatically. Symfony's Doctrine bundle picks it up with zero extra config because config/packages/doctrine.yaml already references it:
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver: 'pdo_pgsql'
server_version: '16'The one gotcha: managed Postgres URLs usually look like postgres://user:pass@host:5432/db, but Doctrine expects postgresql://. Add this to normalize it, or set server_version explicitly to silence the connection probe.
Running migrations safely
Never run doctrine:migrations:migrate inside your image build — the database may not be reachable, and you'll bake a half-migrated state into the image. Run migrations as a release step, after the image is built but before traffic shifts:
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migrationOn PandaStack you can wire this as a one-off job or a cronjob that runs once per release. The --allow-no-migration flag prevents a non-zero exit when there's nothing to apply, which keeps your pipeline green.
Environment variables checklist
| Variable | Value | Notes |
|---|---|---|
APP_ENV | prod | Enables optimized container |
APP_SECRET | random 32+ char string | Used for CSRF, signed URLs |
DATABASE_URL | injected | Auto-wired when DB is linked |
APP_DEBUG | 0 | Never 1 in production |
TRUSTED_PROXIES | REMOTE_ADDR or ingress CIDR | For correct client IPs behind a proxy |
Generate APP_SECRET with php -r 'echo bin2hex(random_bytes(16));' and set it as a secret env var in the dashboard.
Handling the reverse proxy
Behind Kong or any ingress, Symfony needs TRUSTED_PROXIES configured so $request->getClientIp() and HTTPS detection work. Add to config/packages/framework.yaml:
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']Without this, Symfony will generate http:// URLs even when the client is on HTTPS, breaking redirects and absolute links.
Deploying
Push your repo and connect it. The platform detects the Dockerfile, builds the image in an ephemeral build pod, pushes it to the registry, and deploys via Helm. Link your Postgres instance, set the env vars above, and add the migration step. Once live, attach a custom domain and automatic SSL is provisioned for you.
git add Dockerfile
git commit -m "Add production Dockerfile"
git push origin mainLive build and application logs stream in the dashboard, so if cache:warmup fails because of a missing extension, you'll see it immediately rather than guessing.
Common pitfalls
- Permissions on
var/: FrankenPHP runs as a non-root user. Ensurevar/cacheandvar/logare writable. The image above warms the cache at build time so runtime writes are minimal. - OPcache not enabled: Without
opcache, Symfony re-parses thousands of files per request. Theinstall-php-extensions opcacheline handles this. - Forgetting
--classmap-authoritative: This is the single biggest autoloader speedup for production.
Conclusion
Symfony in production is mostly about respecting the prod environment, baking the cache into your image, and keeping migrations as an explicit release step rather than a build step. With a managed Postgres and auto-injected DATABASE_URL, the database side becomes a non-issue.
If you want to skip the infrastructure plumbing, PandaStack's free tier gives you a container web service plus a managed PostgreSQL database to try this end to end. Connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and your Symfony app builds, deploys, and wires up its database automatically.
References
- [Symfony: Deploying to Production](https://symfony.com/doc/current/deployment.html)
- [Symfony: Using Doctrine](https://symfony.com/doc/current/doctrine.html)
- [FrankenPHP Documentation](https://frankenphp.dev/docs/)
- [Doctrine Migrations Bundle](https://www.doctrine-project.org/projects/doctrine-migrations-bundle/en/current/index.html)
- [Composer: Autoloader Optimization](https://getcomposer.org/doc/articles/autoloader-optimization.md)