Back to Blog
Tutorial11 min read2026-07-06

How to Deploy a Symfony PHP App with a Database

A complete guide to deploying a Symfony application to production with a managed PostgreSQL database, covering Dockerfile setup, Doctrine migrations, environment configuration, and zero-downtime releases.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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=prod so 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-fpm behind nginx, or FrankenPHP for 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
└── .env

The 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-migration

On 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

VariableValueNotes
APP_ENVprodEnables optimized container
APP_SECRETrandom 32+ char stringUsed for CSRF, signed URLs
DATABASE_URLinjectedAuto-wired when DB is linked
APP_DEBUG0Never 1 in production
TRUSTED_PROXIESREMOTE_ADDR or ingress CIDRFor 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 main

Live 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. Ensure var/cache and var/log are 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. The install-php-extensions opcache line 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)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also