Deploying PHP Apps

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

# /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
}

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.

# /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>

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.

; /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

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.

# 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.

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.

# .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.

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.

# 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 [email protected]: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

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

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.

# .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"]

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

Common Errors (and the fix)

Pro Tips

📋 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

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

🎉 Lesson Complete!

Practice quiz

What is PHP-FPM's job in a production stack?

  • It serves static CSS and images
  • It compiles JavaScript
  • It actually runs your PHP code; the web server forwards .php requests to it
  • It stores session cookies

Answer: It actually runs your PHP code; the web server forwards .php requests to it. Nginx/Apache don't execute PHP — they forward each .php request to PHP-FPM, which keeps a pool of worker processes ready.

Where should the web server's document root point?

  • The public/ folder, so only index.php is reachable
  • The project root, exposing all files
  • The vendor/ folder
  • The home directory

Answer: The public/ folder, so only index.php is reachable. Point at public/ so only index.php is web-reachable and the rest of your code stays private.

Why must display_errors be Off in production?

  • Errors slow the server down
  • It saves disk space
  • It is required to enable OPcache
  • A crash would print file paths and secrets straight into the visitor's page

Answer: A crash would print file paths and secrets straight into the visitor's page. Set display_errors = Off and log_errors = On so errors go to a private log, not the page — keep error_reporting = E_ALL to still record everything.

What is the single biggest free speed win you enable in production php.ini?

  • memory_limit = 1024M
  • opcache.enable = 1
  • expose_php = On
  • display_errors = On

Answer: opcache.enable = 1. OPcache compiles each PHP file once and reuses the bytecode — typically a 2-3x speed-up with no code changes.

What does 'composer install --no-dev --optimize-autoloader' do?

  • Skips dev-only packages and builds a fast class-map autoloader
  • Installs PHPUnit and debug tools
  • Deletes the vendor folder
  • Upgrades every package to the latest version

Answer: Skips dev-only packages and builds a fast class-map autoloader. --no-dev skips test/debug tools that shouldn't ship; --optimize-autoloader builds a single class-map lookup so the app boots faster.

Where do database passwords and API keys belong?

  • Hard-coded in your PHP source
  • Committed to git so the team can share them
  • In a .env file on the server, listed in .gitignore
  • In the public/ folder

Answer: In a .env file on the server, listed in .gitignore. Secrets live in a server-only .env (never in code or git); PHP reads them at runtime with getenv(). Commit a .env.example with empty values.

Why is switching a symlink with 'ln -sfn' good for zero-downtime deploys?

  • It copies files faster than cp
  • It is atomic — the site flips from old to new in one instant with no half-deployed gap
  • It compresses the release
  • It restarts the database

Answer: It is atomic — the site flips from old to new in one instant with no half-deployed gap. Each release lands in its own timestamped folder and 'current' points at the live one; the atomic symlink swap means visitors never hit a partial deploy.

With opcache.validate_timestamps=0, what must you do after every deploy?

  • Reboot the whole server
  • Run composer update
  • Clear the browser cache
  • Reload PHP-FPM so it loads the new code

Answer: Reload PHP-FPM so it loads the new code. OPcache no longer re-reads files, so 'systemctl reload php8.3-fpm' is what makes a release go live; forget it and the old code keeps serving.

What does a CI/CD pipeline do on each push?

  • Automatically writes new features
  • Installs dependencies, runs tests, and only deploys if they pass
  • Deletes old branches
  • Emails the whole company

Answer: Installs dependencies, runs tests, and only deploys if they pass. Tests gate the deploy, so broken code never ships. Docker takes it further by packaging one identical image.

How should a production Docker container run PHP-FPM?

  • As root for full permissions
  • With display_errors On
  • As an unprivileged user (www-data), never as root
  • Without OPcache

Answer: As an unprivileged user (www-data), never as root. A multi-stage build produces a slim image that runs as www-data — running as root is a security risk.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.