Your Symfony application works perfectly on localhost. But deploying it to production feels like defusing a bomb while blindfolded. One wrong move and everything explodes.

Note: This guide focuses on deploying Symfony on Ubuntu servers. CloudPloy currently supports Laravel applications, with Symfony support coming soon. Stay tuned for updates!

We’ve deployed over 500 Symfony applications to production, from small APIs to enterprise platforms handling millions of requests. The difference between a smooth deployment and a disaster? Having a bulletproof deployment strategy that eliminates guesswork.

This guide reveals our battle-tested Symfony deployment process that achieves zero-downtime deployments, automatic rollbacks, and 99.99% uptime - the same process used by companies processing billions in transactions.

Why Symfony Deployment Is Different

Symfony isn’t just another PHP framework - it’s an enterprise-grade platform with specific deployment requirements:

  • Compiled Container: Symfony’s dependency injection container must be compiled and warmed
  • Environment Configuration: Proper handling of .env files and environment variables
  • Asset Management: Webpack Encore builds and asset versioning
  • Database Migrations: Doctrine migrations must run in the correct order
  • Cache Warming: OpCode cache and application cache optimization

Ignore these requirements and you’ll face the dreaded “500 Internal Server Error” in production.

Pre-Deployment Checklist: Never Deploy Broken Code

1. Environment Configuration

# .env.prod.local (never commit this!)
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=your-secure-secret-key
DATABASE_URL="mysql://user:pass@localhost:3306/db_name"
MAILER_DSN=smtp://user:pass@smtp.example.com:587

2. Security Audit

# Check for security vulnerabilities
composer audit

# Update dependencies
composer update --no-dev --optimize-autoloader

# Check Symfony security
symfony security:check

3. Code Quality Checks

# Run PHP CS Fixer
php-cs-fixer fix src/

# Static Analysis with PHPStan
vendor/bin/phpstan analyse src/ --level=8

# Run Tests
php bin/phpunit

Docker Deployment: The Modern Approach

Multi-Stage Dockerfile for Production

# Stage 1: Build Dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist \
    --ignore-platform-reqs

# Stage 2: Build Assets
FROM node:18-alpine AS assets
WORKDIR /app
COPY package.json yarn.lock webpack.config.js ./
RUN yarn install --frozen-lockfile
COPY assets ./assets
RUN yarn build

# Stage 3: Production Image
FROM php:8.3-fpm-alpine

# Install PHP extensions
RUN apk add --no-cache \
    postgresql-dev \
    icu-dev \
    && docker-php-ext-install \
    pdo_mysql \
    pdo_pgsql \
    intl \
    opcache

# Configure PHP for production
COPY docker/php/php.ini /usr/local/etc/php/
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/

# Copy application
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .

# Set permissions
RUN chown -R www-data:www-data var/cache var/log

# Warm up cache
RUN php bin/console cache:warmup --env=prod

EXPOSE 9000
CMD ["php-fpm"]

Nginx Configuration for Symfony

server {
    listen 80;
    server_name example.com;
    root /var/www/public;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass php:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }

    location ~ \.php$ {
        return 404;
    }

    # Cache static assets
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    error_log /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
}

Zero-Downtime Deployment Strategy

Blue-Green Deployment with Docker Compose

# docker-compose.prod.yml
version: '3.8'

services:
  app_blue:
    build: .
    environment:
      - APP_ENV=prod
      - DATABASE_URL=${DATABASE_URL}
    volumes:
      - ./var:/var/www/var
    networks:
      - symfony_network
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`example.com`)"
      - "traefik.http.services.app.loadbalancer.server.port=9000"

  app_green:
    build: .
    environment:
      - APP_ENV=prod
      - DATABASE_URL=${DATABASE_URL}
    volumes:
      - ./var:/var/www/var
    networks:
      - symfony_network
    labels:
      - "traefik.enable=false"

  nginx:
    image: nginx:alpine
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app_blue
    networks:
      - symfony_network
    ports:
      - "80:80"

networks:
  symfony_network:
    driver: bridge

Deployment Script

#!/bin/bash
# deploy.sh

set -e

echo "Starting deployment..."

# Build new image
docker-compose -f docker-compose.prod.yml build app_green

# Start green container
docker-compose -f docker-compose.prod.yml up -d app_green

# Wait for health check
sleep 10

# Run migrations on green
docker-compose -f docker-compose.prod.yml exec app_green \
    php bin/console doctrine:migrations:migrate --no-interaction

# Switch traffic to green
docker-compose -f docker-compose.prod.yml exec traefik \
    sed -i 's/app_blue/app_green/g' /etc/traefik/dynamic.yml

# Stop blue container
docker-compose -f docker-compose.prod.yml stop app_blue

echo "Deployment complete!"

Database Migration Best Practices

Safe Migration Strategy

// migrations/Version20250909120000.php
namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20250909120000 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        // Add new column with default value (safe)
        $this->addSql('ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT "active"');
        
        // Create index concurrently (PostgreSQL)
        $this->addSql('CREATE INDEX CONCURRENTLY idx_users_status ON users(status)');
    }
    
    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE users DROP COLUMN status');
    }
    
    public function isTransactional(): bool
    {
        // Disable transaction for concurrent index creation
        return false;
    }
}

Migration Deployment Process

# 1. Backup database
mysqldump -u root -p database_name > backup_$(date +%Y%m%d).sql

# 2. Test migrations locally
php bin/console doctrine:migrations:migrate --dry-run

# 3. Apply migrations
php bin/console doctrine:migrations:migrate --no-interaction

# 4. Verify migration status
php bin/console doctrine:migrations:status

Performance Optimization for Production

1. OpCache Configuration

; opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/var/www/config/preload.php
opcache.preload_user=www-data

2. Symfony Performance Settings

# config/packages/prod/framework.yaml
framework:
    cache:
        pools:
            cache.global_clearer:
                adapter: cache.adapter.apcu
    
    router:
        strict_requirements: null
        utf8: true
    
    session:
        handler_id: 'redis://redis:6379'
        cookie_secure: true
        cookie_httponly: true
        cookie_samesite: strict

3. Asset Optimization

// webpack.config.js
const Encore = require('@symfony/webpack-encore');

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .enableVersioning(true)
    .enableSassLoader()
    .enablePostCssLoader()
    .enableBuildNotifications(false)
    .enableSourceMaps(false)
    .enableSingleRuntimeChunk()
    .splitEntryChunks()
    .configureTerserPlugin((options) => {
        options.terserOptions = {
            compress: {
                drop_console: true,
            },
        };
    })
    .enableIntegrityHashes(Encore.isProduction());

module.exports = Encore.getWebpackConfig();

CI/CD Pipeline with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, intl, pdo_mysql
          coverage: xdebug
      
      - name: Install Dependencies
        run: composer install --prefer-dist --no-progress
      
      - name: Run Tests
        run: php bin/phpunit
      
      - name: Security Check
        run: symfony security:check

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Build Docker Image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker tag myapp:${{ github.sha }} myapp:latest
      
      - name: Deploy to Server
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            docker-compose -f docker-compose.prod.yml up -d --build
            docker-compose exec app php bin/console cache:clear
            docker-compose exec app php bin/console doctrine:migrations:migrate --no-interaction

Monitoring and Logging

Application Performance Monitoring

// src/EventListener/PerformanceListener.php
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Psr\Log\LoggerInterface;

class PerformanceListener
{
    private LoggerInterface $logger;
    
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }
    
    public function onKernelTerminate(TerminateEvent $event): void
    {
        $duration = microtime(true) - $event->getRequest()->server->get('REQUEST_TIME_FLOAT');
        
        if ($duration > 1.0) {
            $this->logger->warning('Slow request detected', [
                'url' => $event->getRequest()->getUri(),
                'duration' => $duration,
                'memory' => memory_get_peak_usage(true) / 1024 / 1024,
            ]);
        }
    }
}

Error Tracking with Sentry

# config/packages/sentry.yaml
sentry:
    dsn: '%env(SENTRY_DSN)%'
    options:
        environment: '%kernel.environment%'
        release: '%env(APP_VERSION)%'
        traces_sample_rate: 0.1
        profiles_sample_rate: 0.1

Cloud Deployment Options

AWS EC2 with Auto-Scaling

# cloudformation.yml
Resources:
  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: 2
      MaxSize: 10
      DesiredCapacity: 3
      HealthCheckType: ELB
      HealthCheckGracePeriod: 300
      LaunchTemplate:
        LaunchTemplateId: !Ref SymfonyLaunchTemplate
        Version: !GetAtt SymfonyLaunchTemplate.LatestVersionNumber
      TargetGroupARNs:
        - !Ref ALBTargetGroup

DigitalOcean App Platform

# .do/app.yaml
name: symfony-app
services:
  - name: web
    github:
      repo: username/symfony-app
      branch: main
    build_command: |
      composer install --no-dev --optimize-autoloader
      yarn install && yarn build
      php bin/console cache:warmup
    run_command: php-fpm
    environment_slug: php
    instance_size_slug: professional-xs
    instance_count: 2
    envs:
      - key: APP_ENV
        value: prod
      - key: DATABASE_URL
        value: ${db.DATABASE_URL}
databases:
  - name: db
    engine: MYSQL
    version: "8"

Deployment Troubleshooting Guide

Common Issues and Solutions

1. 500 Error After Deployment

# Clear cache
php bin/console cache:clear --env=prod

# Check permissions
chown -R www-data:www-data var/cache var/log

# Check logs
tail -f var/log/prod.log

2. Database Connection Issues

# Test connection
php bin/console doctrine:query:sql "SELECT 1"

# Check environment variables
php bin/console debug:config doctrine

3. Asset Loading Problems

# Rebuild assets
yarn build

# Install assets
php bin/console assets:install --symlink

Ubuntu Server Setup for Symfony

Initial Server Configuration

# Update Ubuntu packages
sudo apt update && sudo apt upgrade -y

# Install essential packages
sudo apt install -y curl wget git vim nginx mysql-server redis-server supervisor ufw

# Install PHP 8.3 and extensions
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install -y php8.3-fpm php8.3-cli php8.3-common php8.3-mysql \
    php8.3-zip php8.3-gd php8.3-mbstring php8.3-curl php8.3-xml \
    php8.3-bcmath php8.3-intl php8.3-redis php8.3-opcache

# Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# Install Node.js and Yarn
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs
npm install -g yarn

# Configure firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Automated Deployment Script for Ubuntu

#!/bin/bash
# /home/deploy/deploy-symfony.sh

set -e

DEPLOY_USER="deploy"
APP_DIR="/var/www/symfony"
REPO_URL="git@github.com:yourcompany/symfony-app.git"
BRANCH="main"

echo "Starting Symfony deployment on Ubuntu..."

# Pull latest code
cd $APP_DIR
git fetch origin $BRANCH
git reset --hard origin/$BRANCH

# Install dependencies
export COMPOSER_ALLOW_SUPERUSER=1
composer install --no-dev --optimize-autoloader --no-interaction

# Build assets
yarn install --frozen-lockfile
yarn build

# Run migrations
php bin/console doctrine:migrations:migrate --no-interaction

# Clear and warm cache
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod

# Set permissions
sudo chown -R www-data:www-data var/
sudo chmod -R 775 var/

# Restart services
sudo systemctl reload php8.3-fpm
sudo systemctl reload nginx

echo "Deployment complete!"

Conclusion

Deploying Symfony to an Ubuntu server requires careful configuration but provides complete control over your infrastructure. With proper setup of Nginx, PHP-FPM, MySQL, and Redis, plus automated deployment scripts and monitoring, you can achieve enterprise-grade reliability.

This guide has covered everything from initial server setup to zero-downtime deployments, giving you the knowledge to deploy Symfony applications confidently on Ubuntu servers. Remember to always test in staging before deploying to production, maintain regular backups, and monitor your application’s performance continuously.