Skip to main content

    Lesson 47 • Advanced

    Deploying PHP Apps 🚀

    By the end of this lesson you'll be able to take a PHP app from your laptop to a live, hardened server — wiring up Nginx or Apache with PHP-FPM, tuning php.ini for production, installing dependencies the right way, keeping secrets out of git, and shipping new releases with zero downtime.

    What You'll Learn in This Lesson

    • Wire up Nginx (or Apache) in front of PHP-FPM so PHP actually runs
    • Harden php.ini for production: display_errors Off, OPcache On
    • Install dependencies with composer install --no-dev --optimize-autoloader
    • Keep secrets in a .env file on the server, never in your code or git
    • Ship new code with zero-downtime, atomic symlink releases (and roll back)
    • Read a CI/CD pipeline and a multi-stage Dockerfile, and run post-deploy checks

    1️⃣ The Web Server + PHP-FPM

    A web server like Nginx does not run PHP itself. It serves static files (CSS, JS, images) directly and forwards every .php request to PHP-FPM — the "PHP FastCGI Process Manager", a separate program that keeps a pool of worker processes ready to execute your code. The two talk over a Unix socket (a fast local pipe). The golden rule: point the web server at your public/ folder so only index.php is reachable, and the rest of your code stays private.

    Nginx in front of PHP-FPM
    # /etc/nginx/sites-available/myapp.conf
    # Nginx answers the browser. It serves static files itself and hands
    # every .php request to PHP-FPM (the PHP "engine") over a Unix socket.
    
    server {
        listen 80;
        server_name myapp.com www.myapp.com;
    
        # Point at the PUBLIC folder, never the project root.
        # Only index.php should be reachable from the web.
        root /var/www/myapp/current/public;
        index index.php;
    
        # Pretty URLs: unknown paths fall through to the front controller.
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
    
        # The only place PHP runs: forward *.php to PHP-FPM's socket.
        location ~ \.php$ {
            include fastcgi_params;
            fastcgi_pass unix:/run/php/php8.3-fpm.sock;   # talk to PHP-FPM
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        }
    
        # Block secrets: never let .env, .git, etc. be downloaded.
        location ~ /\.   { deny all; }          # any dotfile (.env, .git)
        location ~ \.(sql|log|md)$ { deny all; } # backups, logs, readmes
    }
    Saves to /etc/nginx/sites-available/. Enable it, then sudo nginx -t && sudo systemctl reload nginx.

    Apache is just as common (it ships with cPanel hosting and XAMPP). It connects to the same PHP-FPM socket using mod_proxy_fcgi, so the PHP side is identical — only the web-server syntax differs.

    The Apache equivalent (same PHP-FPM)
    # /etc/apache2/sites-available/myapp.conf
    # The Apache version of the same setup. Apache talks to the SAME PHP-FPM
    # socket using mod_proxy_fcgi (enable it: a2enmod proxy_fcgi setenvif).
    
    <VirtualHost *:80>
        ServerName myapp.com
        DocumentRoot /var/www/myapp/current/public   # public folder only
    
        <Directory /var/www/myapp/current/public>
            AllowOverride All          # lets .htaccess rewrite pretty URLs
            Require all granted
        </Directory>
    
        # Hand .php files to PHP-FPM over its Unix socket.
        <FilesMatch \.php$>
            SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
        </FilesMatch>
    </VirtualHost>
    Saves to /etc/apache2/sites-available/. Enable with a2ensite myapp && systemctl reload apache2.

    2️⃣ Hardening php.ini for Production

    Your local php.ini is tuned for debugging — it prints errors to the screen and re-checks every file on each request. In production you want the opposite. Two settings matter most: display_errors = Off so a crash never prints file paths or secrets onto the page a visitor sees (you log them privately instead), and opcache.enable = 1 so PHP compiles each file once and reuses the result — the single biggest speed win you get for free.

    Production php.ini settings
    ; /etc/php/8.3/fpm/conf.d/99-production.ini
    ; The handful of php.ini settings that separate "works on my laptop"
    ; from "safe and fast in production". Reload PHP-FPM after editing.
    
    ; --- Never leak errors to visitors ---
    display_errors = Off          ; do NOT print errors into the page (leaks paths)
    log_errors = On               ; write them to a log file instead
    error_log = /var/log/php/error.log
    error_reporting = E_ALL       ; still RECORD everything, just don't display it
    
    ; --- OPcache: compile each PHP file once, then reuse it (huge speed win) ---
    opcache.enable = 1
    opcache.memory_consumption = 256       ; MB of RAM for compiled bytecode
    opcache.max_accelerated_files = 20000  ; raise above your file count
    opcache.validate_timestamps = 0        ; 0 = don't re-check files on every
                                           ; request (fastest). You MUST reload
                                           ; PHP-FPM on deploy so it sees new code.
    
    ; --- Sensible limits ---
    expose_php = Off              ; hide the "X-Powered-By: PHP" header
    upload_max_filesize = 16M
    post_max_size = 16M
    memory_limit = 256M
    Output
    $ php -i | grep -E "display_errors|opcache.enable|opcache.validate_timestamps"
    display_errors => Off => Off
    opcache.enable => On => On
    opcache.validate_timestamps => 0 => 0
    Drop this in conf.d/, then sudo systemctl reload php8.3-fpm. Verify with the php -i | grep command shown in the Output panel.

    One gotcha with opcache.validate_timestamps = 0: because OPcache no longer re-reads files, it won't see your new code until you reload PHP-FPM on every deploy. That single systemctl reload is what makes a release "go live" — forget it and the server keeps serving the old code.

    3️⃣ Installing Dependencies the Production Way

    On the server you never run a plain composer install. You add two flags. --no-dev skips development-only packages (PHPUnit, debug toolbars) that should never reach production. --optimize-autoloader builds a single class-map lookup table instead of scanning folders on every request, so your app boots faster. Add --classmap-authoritative to trust that map fully and skip filesystem checks.

    The production install command
    # Build the dependencies the production server actually needs.
    # Run this on the server (or in CI) AFTER pulling new code.
    
    # --no-dev               : skip PHPUnit, debug tools — they don't ship to prod
    # --optimize-autoloader  : build a fast class map instead of scanning folders
    # --classmap-authoritative : trust the class map fully (skips filesystem checks)
    composer install --no-dev --optimize-autoloader --classmap-authoritative
    
    # Why it matters: a dev install loads classes by searching directories on
    # every request. The optimised autoloader turns that into a single lookup
    # table, so production boots noticeably faster.
    Output
    Installing dependencies from lock file
    Package operations: 78 installs, 0 updates, 0 removals
    Generating optimized autoload files (authoritative)
    > Composer\Config::disableProcessTimeout
    78 packages you are using are looking for funding!
    Run in the project root on the server (or in CI). Commit your composer.lock so the server installs the exact versions you tested.

    4️⃣ Environment Variables & Secrets

    Database passwords and API keys must never live in your PHP source or in git — anyone who clones the repo would get them. Instead they go in a .env file that exists only on the server and is listed in .gitignore. Your code reads them at runtime with getenv() (or a framework helper), so the same code runs everywhere and only the values change between machines.

    A .env file on the server
    # .env  — lives ON THE SERVER ONLY, listed in .gitignore, never committed.
    # Secrets (DB passwords, API keys) belong here, not in your PHP source.
    APP_ENV=production
    APP_DEBUG=false                 # OFF in production (no stack traces to users)
    APP_KEY=base64:Xk3...generated  # used to encrypt sessions/cookies
    
    DB_HOST=127.0.0.1
    DB_DATABASE=myapp
    DB_USERNAME=myapp
    DB_PASSWORD=super-secret-value  # the real secret lives only here
    
    # In PHP you read these with getenv() (or your framework's helper):
    #   $host = getenv('DB_HOST');         // "127.0.0.1"
    #   $pass = getenv('DB_PASSWORD');     // the secret, loaded from the server
    #
    # Commit a .env.example with EMPTY values so teammates know the keys
    # without ever seeing the secrets.
    Add .env to .gitignore. Commit a .env.example with empty values so teammates know which keys to set.

    5️⃣ Zero-Downtime Releases

    Copying new files over a running site is risky — for a few seconds visitors hit a half-updated app. The fix is the symlink release pattern. Each deploy lands in its own timestamped folder, and a symlink called current points at the live one. Because switching a symlink with ln -sfn is atomic (it happens in one instant), the site flips from old to new with zero gap — and rolling back is just pointing the symlink at the previous folder.

    Atomic symlink deploy
    # Zero-downtime deploy with the "symlink releases" pattern.
    # /var/www/myapp/current is a SYMLINK. The live site always serves whatever
    # it points at, and switching a symlink is instant + atomic — so visitors
    # never hit a half-deployed site.
    
    # Layout on the server:
    #   /var/www/myapp/
    #     releases/
    #       2026-06-16-101500/   <- new code just pulled here
    #       2026-06-15-180000/   <- previous release (kept for rollback)
    #     shared/.env            <- secrets, shared across all releases
    #     current -> releases/2026-06-16-101500   (the symlink)
    
    cd /var/www/myapp
    REL="releases/$(date +%Y-%m-%d-%H%M%S)"            # unique folder per deploy
    
    git clone --depth 1 git@github.com:me/myapp.git "$REL"
    cd "$REL"
    composer install --no-dev --optimize-autoloader    # build deps in the NEW dir
    ln -s /var/www/myapp/shared/.env .env              # link in the shared secrets
    
    cd /var/www/myapp
    ln -sfn "$REL" current        # ATOMIC swap: 'current' now points at new code
    sudo systemctl reload php8.3-fpm   # bust OPcache so it loads the new files
    
    # Rollback is just pointing the symlink back:
    #   ln -sfn releases/2026-06-15-180000 current && sudo systemctl reload php8.3-fpm
    Output
    $ ls -l /var/www/myapp/current
    lrwxrwxrwx  current -> releases/2026-06-16-101500
    $ curl -s -o /dev/null -w "%{http_code}\n" https://myapp.com
    200
    The ln -sfn "$REL" current line is the moment the release goes live. Keep the last few release folders so rollback is instant.

    Now you try. The php.ini below is almost production-ready — fill in each ___ using the 👉 hint, reload PHP-FPM, and check it against the Output panel.

    🎯 Your turn: harden php.ini
    ; 🎯 YOUR TURN — harden this php.ini for production.
    ; Fill in each blank marked ___ using the 👉 hint, then reload PHP-FPM.
    
    ; 1) Visitors must NEVER see raw PHP errors (they leak file paths / secrets).
    display_errors = ___          ; 👉 the word that turns it OFF:  Off
    
    ; 2) But you still want errors recorded somewhere you can read them.
    log_errors = ___              ; 👉 the word that turns it ON:  On
    
    ; 3) Turn on OPcache so PHP compiles each file once and reuses it.
    opcache.enable = ___          ; 👉 the digit that means enabled:  1
    
    ; ✅ Expected (after reload, php -i shows):
    ;    display_errors => Off
    ;    log_errors     => On
    ;    opcache.enable => On
    Output
    display_errors => Off => Off
    log_errors => On => On
    opcache.enable => On => On
    Fill the three ___ blanks (Off, On, 1), reload PHP-FPM, then run php -i to confirm. Errors are now logged, never shown.

    6️⃣ CI/CD & Docker Overview

    You don't want to deploy by hand. A CI/CD pipeline (Continuous Integration / Continuous Deployment) runs on every push: it installs dependencies, runs your tests, and only deploys if they pass. Docker takes it further by packaging your exact PHP version, extensions, and code into one image that runs identically everywhere. A multi-stage build installs Composer deps in a throwaway stage and copies only the result into a slim final image — and that image runs as www-data, never root.

    CI/CD pipeline + multi-stage Dockerfile
    # .github/workflows/deploy.yml — a CI/CD pipeline that tests, then deploys.
    name: Deploy
    on:
      push:
        branches: [ main ]          # deploy on every push to main
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: shivammathur/setup-php@v2
            with: { php-version: '8.3' }
          - run: composer install --no-dev --optimize-autoloader
          - run: vendor/bin/phpunit          # tests MUST pass before we deploy
          - name: Deploy on success
            run: ./deploy.sh                  # the symlink-swap script from above
    
    ---
    # Dockerfile — a multi-stage build: install deps in one stage, copy the
    # result into a slim runtime image so the shipped image stays small.
    FROM composer:2 AS build
    WORKDIR /app
    COPY . .
    RUN composer install --no-dev --optimize-autoloader
    
    FROM php:8.3-fpm-alpine                   # tiny production base image
    RUN docker-php-ext-install pdo_mysql opcache
    COPY --from=build /app /var/www/html      # bring in code + vendor/ only
    USER www-data                             # run UNPRIVILEGED, never as root
    CMD ["php-fpm"]
    Output
    Run vendor/bin/phpunit
    PHPUnit 11.2.0
    ................................   32 / 32 (100%)
    OK (32 tests, 88 assertions)
    Deploy on success
    Release switched -> releases/2026-06-16-101500  (HTTP 200)
    Top half is a GitHub Actions workflow; bottom half a Dockerfile. Tests gate the deploy, so broken code never ships. Build locally with docker build -t myapp ..

    One more. Write the production install command CI should run — add the two flags from the 👉 hints so dev tools are skipped and the autoloader is optimised.

    🎯 Your turn: the production install command
    # 🎯 YOUR TURN — write the production install command.
    # Fill in the two flags so dev tools are skipped and the autoloader is fast.
    
    # Start from "composer install" and add:
    #   • the flag that EXCLUDES dev dependencies
    #   • the flag that BUILDS THE OPTIMIZED autoloader
    composer install ___ ___
    # 👉 first blank:  --no-dev
    # 👉 second blank: --optimize-autoloader
    
    # ✅ Expected output ends with:
    #    Generating optimized autoload files
    Output
    Installing dependencies from lock file
    Nothing to install, update or remove
    Generating optimized autoload files
    Fill the two ___ blanks with --no-dev and --optimize-autoloader. The output should end with "Generating optimized autoload files".

    Common Errors (and the fix)

    • "502 Bad Gateway" from Nginx — Nginx can't reach PHP-FPM. The fastcgi_pass socket path doesn't match PHP-FPM's listen path, or PHP-FPM is stopped. Confirm the socket exists (ls /run/php/) and run sudo systemctl status php8.3-fpm.
    • The browser downloads your index.php instead of running it — the web server has no PHP handler. You're missing the location ~ \.php$ block (Nginx) or SetHandler proxy:... (Apache) that forwards .php to PHP-FPM.
    • You deployed but the site still shows old code — OPcache is serving the cached bytecode. With opcache.validate_timestamps=0 you must run sudo systemctl reload php8.3-fpm at the end of every deploy.
    • "Class not found" only on the server — you ran --no-dev but the class lives in a require-dev package, or you forgot to regenerate the autoloader. Run composer dump-autoload --optimize and check the package is in require, not require-dev.
    • A visitor saw your database password in a stack tracedisplay_errors was left On. Set it Off and log_errors = On immediately, then rotate the leaked secret.

    Pro Tips

    • 💡 Add a .dockerignore excluding vendor/, .git/, and node_modules/ — it shrinks the build context and speeds builds dramatically.
    • 💡 Run a post-deploy health check. End your script with curl -fsS https://myapp.com/health || rollback so a failed deploy reverts itself automatically.
    • 💡 Get free HTTPS with Let's Encrypt: sudo certbot --nginx -d myapp.com issues and auto-renews a certificate. Never ship a production site on plain HTTP.

    📋 Quick Reference — Deployment

    TaskCommand / SettingWhat It Does
    Install (prod)composer install --no-dev --optimize-autoloaderProd deps + fast autoloader
    Hide errorsdisplay_errors = OffDon't leak errors to visitors
    Speed up PHPopcache.enable = 1Compile each file once, reuse it
    Go liveln -sfn "$REL" currentAtomic, zero-downtime swap
    Apply releasesystemctl reload php8.3-fpmBust OPcache, load new code
    Reload Nginxnginx -t && systemctl reload nginxTest config, then reload safely
    Free HTTPScertbot --nginx -d myapp.comIssue + auto-renew SSL cert

    Frequently Asked Questions

    Q: What is PHP-FPM and why do I need it?

    PHP-FPM (FastCGI Process Manager) is the program that actually runs your PHP code. A web server like Nginx or Apache does not execute PHP itself — it forwards each .php request to PHP-FPM, which keeps a pool of worker processes ready to handle requests in parallel. This split lets Nginx serve static files (CSS, images, JS) at full speed while PHP-FPM focuses only on dynamic pages, and it lets you tune how many PHP workers run independently of the web server.

    Q: Why must display_errors be Off in production?

    When display_errors is On, a crash prints the full error — including file paths, database details, and sometimes credentials — straight into the page the visitor sees. That is both ugly and a security leak that helps attackers map your system. In production you set display_errors = Off and log_errors = On, so errors are written to a private log file you can read while visitors only ever see a clean error page. Keep error_reporting = E_ALL so everything is still recorded — you are hiding errors from users, not from yourself.

    Q: What does --no-dev --optimize-autoloader actually change?

    --no-dev skips development-only packages such as PHPUnit and debug bars, which should never ship to a live server. --optimize-autoloader builds a single class map up front, so PHP can locate any class with one lookup instead of scanning directories on every request. Together they give you a smaller install and a faster boot. Adding --classmap-authoritative goes one step further by trusting that map completely and skipping filesystem checks, which is safe as long as you do not autoload classes that are generated at runtime.

    Q: How does the symlink release pattern give zero downtime?

    You deploy each release into its own timestamped folder (for example releases/2026-06-16-101500) and point a symlink called current at it. The live site always serves whatever current points to. Because switching a symlink with 'ln -sfn' is atomic — it happens in a single instant — there is no moment when the site is half-updated. Visitors mid-request finish on the old code, new requests hit the new code, and a rollback is just pointing the symlink back at the previous folder and reloading PHP-FPM.

    Q: Should I deploy with Docker or with a plain server and symlinks?

    Both are valid. A plain Linux server with Nginx, PHP-FPM, and the symlink-release pattern is simple, cheap, and easy to reason about — a great default for a single app. Docker shines when you want the exact same image to run identically on every machine, when you have several services (app, database, cache) to coordinate, or when you deploy to a container platform. The PHP fundamentals are the same either way: a slim image, OPcache on, display_errors off, --no-dev installs, and never running as root.

    Mini-Challenge: Your Own Deploy Script

    No code is filled in this time — just a brief and an outline. Write the zero-downtime deploy script yourself, run it against a test VPS (or a folder on your machine to dry-run the symlink logic), then check it against the expected output in the comments. This is the exact write-run-check loop you'll use to ship real releases.

    🎯 Mini-Challenge: write a zero-downtime deploy script
    # 🎯 MINI-CHALLENGE: write your own zero-downtime deploy script.
    # No code is filled in — work from the steps, then run it on a test server.
    #
    # 1. cd into /var/www/myapp
    # 2. Make a unique release folder name from the date,
    #    e.g.  REL="releases/$(date +%Y-%m-%d-%H%M%S)"
    # 3. git clone --depth 1 your repo into "$REL"
    # 4. Inside "$REL" run: composer install --no-dev --optimize-autoloader
    # 5. Link the shared secrets:  ln -s ../../shared/.env .env
    # 6. Atomically point the symlink at the new release:
    #       ln -sfn "$REL" current
    # 7. Reload PHP-FPM so OPcache picks up the new code:
    #       sudo systemctl reload php8.3-fpm
    #
    # ✅ Expected: 'ls -l current' shows it points at your new release,
    #    and 'curl -I https://myapp.com' returns HTTP/1.1 200 OK.
    
    # your code here
    Clone into a fresh timestamped folder, install prod deps, link shared secrets, swap the current symlink atomically, then reload PHP-FPM. Confirm with curl -I returning 200.

    🎉 Lesson Complete!

    • Nginx or Apache sits in front and forwards .php to PHP-FPM over a Unix socket
    • ✅ Production php.ini means display_errors = Off and opcache.enable = 1
    • ✅ Install with composer install --no-dev --optimize-autoloader
    • ✅ Secrets live in a server-only .env file, never in code or git
    • ✅ Ship with atomic symlink releases for zero downtime, then reload php8.3-fpm
    • CI/CD tests before it deploys; Docker ships one identical image, run as www-data
    • Next lesson: PHP Architecture — structure a complete app as a clean, modular monolith

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service