Deployment

Deploy your Symfony application to production

Basic Deployment

frankendeploy deploy prod

One command, a complete blue-green deployment:

  1. Pre-flight check of the environment variables the app needs on the server (APP_SECRET, DATABASE_URL for an external database)
  2. Generates missing Docker artifacts (Dockerfile, docker-entrypoint.sh, .dockerignore, Caddyfile in worker mode). No need to run build first; customized files are never overwritten
  3. Builds the Docker image, locally or on the server with remote build, and transfers it
  4. Ensures the application’s isolated Docker network exists
  5. Starts the managed database container if configured, and injects DATABASE_URL
  6. Starts the new container next to the old one, backs up the database, runs the pre_deploy hooks (migrations)
  7. Runs the health check on the new container
  8. Switches traffic to the new container (zero downtime), then stops the old one
  9. Restarts the Messenger worker on the new image, runs the post_deploy hooks
  10. Writes the Caddy configuration for the domain and reloads the proxy
  11. Cleans up old releases, images and backups beyond keep_releases

If any step fails before the switch, the old container keeps serving traffic untouched. Before a first deploy, run frankendeploy doctor prod.

Deployment Options

Custom Tag

frankendeploy deploy prod --tag v1.2.3

By default, tags are timestamps like 20260902-143052. The tag names the release and the Docker image.

Cross-Architecture Detection

FrankenDeploy compares the architecture of your machine and of the server. Deploying from Apple Silicon (arm64) to an x86_64 VPS, you see:

⚠️  Architecture mismatch detected:
   Local:  arm64 (Apple Silicon)
   Server: x86_64

   Local builds will not run on this server.

In interactive mode, FrankenDeploy offers to enable remote build and saves the preference on the server entry (remote_build: true in the global config).

In CI/CD mode (--yes), either pass --remote-build or configure the server once:

frankendeploy server set prod remote_build true

Build the Docker image on the server instead of locally:

frankendeploy deploy prod --remote-build

How it works:

  1. The source code is transferred over the existing SSH connection (pure-Go SFTP: no rsync or scp needed, works on Windows; .git, node_modules, vendor, var and .env.local are excluded)
  2. The image is built on the VPS, with Docker’s layer cache, so the second build is much faster than the first
  3. The deploy continues normally

Recommended when your machine has a different architecture than the server, when local builds are slow, or when your upload bandwidth is small (source code is much smaller than an image).

Force Local Build

If remote build is configured on the server but you want a local build this time:

frankendeploy deploy prod --no-remote-build

Skip Build

If the image with this tag already exists locally:

frankendeploy deploy prod --no-build --tag v1.2.3

Skip Individual Checks

# Skip the pre-flight environment variables check
frankendeploy deploy prod --skip-env-check

# Skip the health check entirely (traffic switches unverified)
frankendeploy deploy prod --skip-healthcheck

Force Deploy

--force skips the env pre-flight and continues even when the database backup, the pre_deploy hooks (migrations) or the health check fail. Use with care:

frankendeploy deploy prod --force

Health Checks

FrankenDeploy verifies that the new container answers before switching traffic to it.

deploy:
  healthcheck_path: /health

The default path is /. For API Platform projects, init sets it to /api automatically: a pure API returns 404 on /, which would fail every health check. The same path is used by the Docker HEALTHCHECK baked into the image, so docker ps health status reflects the application actually answering, not just the web server process being up.

The reverse proxy deliberately runs no active health check on the live container: with a single upstream there is nothing to fail over to, and a probe timing out under load would turn a slow application into a 503 for every visitor. Per-request timeouts apply instead (5 s to connect, 60 s for the response headers): a container that accepts connections but never answers costs the affected visitor a 504, not an endless wait.

The check window is generous by default (90 seconds: a cold Symfony container needs time for opcache warmup and database wait) and tunable:

deploy:
  healthcheck_timeout: 90    # overall window in seconds
  healthcheck_retries: 30    # max attempts
  healthcheck_interval: 3    # seconds between attempts

When the health check fails, FrankenDeploy prints the last 50 log lines of the failing container before removing it, so you immediately see the real cause (missing variable, failed migration, PHP fatal…).

A good health endpoint proves the app works, not only that PHP runs:

// src/Controller/HealthController.php
#[Route('/health')]
public function health(Connection $connection): Response
{
    $connection->executeQuery('SELECT 1');

    return new Response('OK');
}

Deployment Hooks

deploy:
  hooks:
    pre_deploy:
      - php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
    post_deploy:
      - php bin/console cache:pool:clear cache.app

pre_deploy hooks run in the new container, before traffic is switched, while the old version still serves requests. A failure removes the new container and aborts the deploy (unless --force).

post_deploy hooks run in the live container after the switch. A failure only warns.

No cache:warmup hook is needed: the Symfony cache is warmed when the image is built.

Database Migration Warning

FrankenDeploy detects when the project has Doctrine entities but no migration files, a classic when you forget make:migration after creating entities:

⚠️  Warning: No database migrations found but entities exist!

   Entities found: 5 files in src/Entity/
   Migrations:     0 files in migrations/

   This may cause 'no such table' errors at runtime.

   To fix this, run locally:
      php bin/console make:migration
      php bin/console doctrine:migrations:migrate
      git add migrations/
      git commit -m "Add database migrations"

   Then redeploy your application.

The warning appears once per application and clears itself once migrations exist. It only runs when a migration hook is configured in pre_deploy.

Automatic Database Backup Before Migrations

With a managed database (database.managed: true), FrankenDeploy dumps the database before any pre_deploy hook containing doctrine:migrations:migrate:

  • The gzipped dump is stored on the server in /opt/frankendeploy/apps/<app>/shared/backups/ (permissions 600)
  • Retention follows deploy.keep_releases (default: 5 backups)
  • A failed backup aborts the deploy. --force deploys without this safety net (not recommended)

Why this matters: migrations run before the traffic switch, while the old code still serves requests. If the health check fails after a successful migration, the containers are rolled back but the database schema is not: the previous version then runs on the new schema. When that happens, FrankenDeploy says so and points to the backup:

⚠️  The database was already migrated during this deploy. Rolling back the code may not be enough...
⚠️  Database backup taken before the migration: /opt/frankendeploy/apps/my-app/shared/backups/pgsql-20260902-120000.sql.gz

Restore example (PostgreSQL):

gunzip -c backups/pgsql-<tag>.sql.gz | docker exec -i <app>-db psql -U <user> <db>

Best practice: write backward-compatible migrations (expand/contract: add columns before using them, drop them one release later). A code rollback then stays safe even after a migration.

For an external database, no automatic backup is possible: back it up yourself before deploying migrations.

Messenger Workers

With messenger.enabled: true, each deploy starts one <app>-worker container from the same image, running:

php bin/console messenger:consume <transports> --time-limit=3600 --memory-limit=256M

The worker is stopped and recreated after the traffic switch, so it always runs the same code as the app, and rollback does the same. --time-limit and --memory-limit make it restart cleanly (Docker’s restart: unless-stopped brings it back), which is the recommended way to run long-lived PHP workers. A failure to start the worker warns but does not fail the deploy.

frankendeploy logs prod --service worker      # worker logs
frankendeploy logs prod --service all -f      # app and worker

Release Management

deploy:
  keep_releases: 5

Releases are stored in /opt/frankendeploy/apps/<app>/releases/<tag>/, with current pointing to the live one. keep_releases also drives disk usage: after each deploy, FrankenDeploy removes the Docker images whose tag left the retention window (never an image in use by a container), and the database backups beyond the same count. With remote builds, dangling intermediate layers are pruned after each build. Rollback targets and disk retention therefore always match.

frankendeploy app status prod

Environment Variables

Production variables live on the server, per application, in /opt/frankendeploy/apps/<app>/shared/.env.local, and are managed with frankendeploy env:

openssl rand -hex 32 | frankendeploy env set prod APP_SECRET --from-stdin
frankendeploy env set prod MAILER_DSN="smtp://..." --reload

FrankenDeploy itself sets SERVER_NAME, APP_ENV=prod, APP_DEBUG=0 and, with a managed database, DATABASE_URL. The env.prod section of frankendeploy.yaml only feeds the generated compose.prod.yaml and is not read by deploy. See Environment Variables.

Shared Files and Directories

Paths that persist between releases, mounted into every container from /opt/frankendeploy/apps/<app>/shared/:

deploy:
  shared_files:
    - .env.local          # mounted read-only
  shared_dirs:
    - var/log
    - var/sessions
    - public/uploads

Zero-Downtime Deployment

  1. The new container starts next to the old one, under a temporary name
  2. Migrations run and the health check verifies the new container
  3. Traffic switches through a rename-based swap: the old container is renamed away while still running, the new one takes the app name, and Docker’s embedded DNS follows the name, so Caddy never sees a missing upstream
  4. The old container is stopped

If anything fails, including the swap itself, traffic stays on the old container.

Network Isolation

Each application runs on its own Docker network, frankendeploy-<app>, with its worker and its managed database. Caddy is attached to every app network so it can reach <app>:8080 by name; nothing else is. Two applications deployed on the same VPS cannot reach each other’s containers, so a compromised app cannot talk to another app’s database.

The network is created by the first deploy. An application deployed before per-app networks existed (FrankenDeploy < 0.15) is migrated transparently on its next deploy: the database container joins the app network, the new app container starts on it, and once the old container is stopped the database leaves the shared network. Every step checks the current state before acting, so a deploy interrupted in the middle completes on the next run. frankendeploy doctor reports the isolation status of the app.

Behind the Proxy

Requests reach your application through Caddy: Internet → Caddy (80/443, TLS) → app container (:8080, plain HTTP on the private app network). Caddy forwards the real client IP and the original scheme in X-Forwarded-For and X-Forwarded-Proto, but Symfony only honours those headers from proxies it trusts. Otherwise it believes it serves plain HTTP to Caddy’s IP: absolute URLs come out in http://, session cookies lack the Secure flag, rate limiters and logs see the proxy instead of the visitor.

FrankenDeploy handles it: every app container receives

SYMFONY_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
  • Symfony >= 7.2 reads SYMFONY_TRUSTED_PROXIES natively: nothing to configure in the application.
  • Older Symfony applications need the classic wiring 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']

Trusting the private subnets is safe here: since each app runs on its own Docker network, Caddy is the only thing that can reach the container, so the only proxy Symfony trusts is the one FrankenDeploy put in front of it.

To use other values, define SYMFONY_TRUSTED_PROXIES or TRUSTED_PROXIES yourself with frankendeploy env set: when either is present in the server’s .env.local, FrankenDeploy injects nothing (a docker run -e value would otherwise override yours). SYMFONY_TRUSTED_HOSTS is not needed: Caddy only routes the configured domain to the container.

To check what Symfony sees, look at an absolute URL it generates. With API Platform:

curl -sI https://my-app.com/api | grep -i link
# link: <https://my-app.com/api/docs.jsonld>; rel="..."   ← https, the scheme is trusted

Managed Database

With database.managed: true (the default for PostgreSQL, MySQL and MariaDB), the first deploy creates the <app>-db container with random credentials and a persistent volume, and every deploy makes sure it runs and injects DATABASE_URL into the app. Credentials are saved on the server (shared/.db_credentials, permissions 600) and reused: you never set DATABASE_URL yourself, and the database survives deploys, rollbacks and reboots.

Monitoring Deployments

frankendeploy deploy prod --verbose       # every command run on the server
frankendeploy app status prod             # container status, releases
frankendeploy logs prod -f                # follow the app logs
frankendeploy logs prod --since 10m       # last 10 minutes
frankendeploy logs prod --tail 500        # last 500 lines

CI/CD Integration

Deploying from GitHub Actions, GitLab CI or any runner takes the CLI, a deploy key and a server entry: complete recipes, the --yes semantics and the environment variables are in the CI/CD guide.