Symfony is one of the frameworks that gets *more* opinionated about production, not less. There's a real distinction between dev and prod environments, a compiled container, cache warmup, environment variable compilation, and a migration system that expects to be run deliberately. That's all good — it means fewer surprises at runtime — but it also means "just upload the files" is not a deployment strategy. Here's the full path from a working local app to a live deployment with a managed PostgreSQL database.
The production build, step by step
A production Symfony build has four distinct stages, and the order matters.
1. Install without dev dependencies:
APP_ENV=prod composer install --no-dev --optimize-autoloaderSet APP_ENV=prod *before* running this. Composer's auto-scripts hook runs cache:clear after install, and if the environment is still dev, the kernel will try to boot dev-only bundles (WebProfiler, Debug) that you just excluded with --no-dev. The classic symptom is a ClassNotFoundError for WebProfilerBundle during what looks like an innocent install.
2. Compile environment variables:
composer dump-env prodThis writes .env.local.php — a compiled PHP array of your env files. In production, Symfony reads that file instead of parsing .env on every request. Real secrets (APP_SECRET, DATABASE_URL) should still come from actual environment variables, which always win over the compiled file.
3. Build assets. With AssetMapper (the default since Symfony 6.4):
php bin/console asset-map:compileThis dumps versioned assets into public/assets/. If you're on Webpack Encore instead, it's npm ci && npm run build, which outputs to public/build/.
4. Warm the cache:
APP_ENV=prod php bin/console cache:warmupWarmup compiles the service container, routes, and Twig templates ahead of time, so the first real request doesn't pay that cost.
Connecting the database
Here's where Symfony and PandaStack fit together with zero glue code: Doctrine's canonical configuration is a single DATABASE_URL environment variable — and that's exactly what PandaStack injects when you attach a managed database to an app. No parsing, no adapter, no copying five discrete credentials.
One detail worth handling explicitly: Doctrine wants to know the server version so it can pick the right SQL dialect without an extra connection at boot. If your injected URL doesn't carry a serverVersion query parameter, pin it in config:
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
server_version: '16'PandaStack's managed PostgreSQL runs 14.x and 16.x, so set the value to match the instance you provision. Both postgresql:// and postgres:// URL schemes work — Doctrine treats them as aliases.
Migrations: run them deliberately
Doctrine Migrations is forward-only by design in production. The command you want:
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration--no-interaction skips the "are you sure?" prompt (there's no TTY in a deploy pipeline), and --allow-no-migration makes the command exit 0 when there's nothing to run — without it, a deploy with no schema changes fails the step.
Two rules that save you from real incidents:
- Never run
doctrine:schema:update --forcein production. It diffs your entities against the live schema and applies whatever it computes — including destructive changes migrations would have made explicit. - Run migrations as a separate release step before the new version takes traffic, not in the container's entrypoint. If two replicas boot simultaneously and both try to migrate, you get lock contention at best and a half-applied schema at worst.
A production Dockerfile with FrankenPHP
PHP apps deploy cleanest as containers, and FrankenPHP (Caddy with PHP embedded) has become the sane default: one process, one port, no separate FPM + nginx pairing to configure. This Dockerfile follows the two-phase Composer pattern — install dependencies before copying source, so the vendor layer caches across builds:
FROM dunglas/frankenphp:1-php8.3
# Listen on plain HTTP :8080 — TLS terminates at the platform edge
ENV SERVER_NAME=:8080
ENV APP_ENV=prod
RUN install-php-extensions pdo_pgsql intl opcache zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Phase 1: dependencies only (cached layer)
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress
# Phase 2: source, then the scripts that need it
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative \
&& composer dump-env prod \
&& php bin/console asset-map:compile \
&& php bin/console cache:warmup
EXPOSE 8080The dunglas/frankenphp image serves /app/public by default, which is exactly where Symfony's front controller lives — no Caddyfile edits needed. cache:warmup works at build time without a database connection; Doctrine connects lazily on first use.
Logs and proxies: two prod-only gotchas
Send logs to stderr. On a container platform, anything written to var/log/prod.log disappears with the pod. Point Monolog at the stream the platform actually collects:
# config/packages/monolog.yaml
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
nested:
type: stream
path: php://stderr
level: debugPandaStack streams app logs live from stdout/stderr, so this is what makes 500 errors actually visible in the dashboard instead of dying inside the container.
Trust the proxy. Behind an ingress, Symfony sees the load balancer's IP as the client and plain HTTP as the scheme — which breaks https:// URL generation and anything IP-based. Tell it to trust the forwarding headers:
# config/packages/framework.yaml
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']Then set TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 (the private ranges the ingress lives in) as an environment variable.
Deploying on PandaStack
With the Dockerfile in the repo, the deployment itself is short:
- 1Provision a managed PostgreSQL instance (16.x) from the dashboard.
- 2Connect your Symfony repo as a container app. PandaStack builds any Dockerfile — images are built with rootless BuildKit in ephemeral Kubernetes Job pods, so there's no host Docker daemon in the path.
- 3Attach the database to the app.
DATABASE_URLis injected automatically, and since that's Doctrine's native configuration variable, there is genuinely nothing to wire. - 4Set the remaining environment variables:
APP_ENV=prod, a realAPP_SECRET(generate one withphp -r "echo bin2hex(random_bytes(16));"), andTRUSTED_PROXIESfrom above. - 5Push. The build runs with live logs streamed to the dashboard, and the app goes live on your subdomain — add a custom domain and SSL is handled automatically.
- 6Run
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migrationas a release step (or one-off command) before the new version takes traffic.
On the free tier the app scales to zero when idle and cold-starts on the next request — fine for staging and side projects; paid plans keep instances warm on stable nodes.
Pre-flight checklist
APP_ENV=prodset at build *and* runtimeAPP_SECRETis a real random value, not the scaffolded oneserver_versionpinned indoctrine.yaml- Migrations run as a release step, never in the entrypoint
- Monolog writing to
php://stderr trusted_proxiesconfigured, or your HTTPS redirects will loop
None of these are exotic — they're just the difference between Symfony working on your laptop and Symfony working behind an ingress with replicas. If you want to try the loop end to end, connect a repo at [pandastack.io](https://pandastack.io) and push.