Back to Blog
Tutorial7 min read2026-07-13

How to Deploy Symfony to Production with PandaStack

Deploy a Symfony app with managed PostgreSQL: production build flags, Doctrine migrations, a FrankenPHP Dockerfile, and going live on Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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-autoloader

Set 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 prod

This 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:compile

This 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:warmup

Warmup 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 --force in 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 8080

The 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: debug

PandaStack 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:

  1. 1Provision a managed PostgreSQL instance (16.x) from the dashboard.
  2. 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.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically, and since that's Doctrine's native configuration variable, there is genuinely nothing to wire.
  4. 4Set the remaining environment variables: APP_ENV=prod, a real APP_SECRET (generate one with php -r "echo bin2hex(random_bytes(16));"), and TRUSTED_PROXIES from above.
  5. 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.
  6. 6Run php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration as 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=prod set at build *and* runtime
  • APP_SECRET is a real random value, not the scaffolded one
  • server_version pinned in doctrine.yaml
  • Migrations run as a release step, never in the entrypoint
  • Monolog writing to php://stderr
  • trusted_proxies configured, 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also