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
php.ini, deploy, and Docker snippets below are what you actually put on a Linux server. There's no in-browser runner for server config, so each block is shown with the command you'd run and its expected output. Spin up a cheap VPS, or install PHP and Docker locally, to try them end-to-end.composer install and the vendor/ folder are new to you, do Composer Packages first — this lesson builds directly on it.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
}/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.
# /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>/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.
; /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$ php -i | grep -E "display_errors|opcache.enable|opcache.validate_timestamps"
display_errors => Off => Off
opcache.enable => On => On
opcache.validate_timestamps => 0 => 0conf.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.
# 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.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!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.
# .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..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.
# 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$ 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
200ln -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 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 => Ondisplay_errors => Off => Off
log_errors => On => On
opcache.enable => On => On___ 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.
# .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"]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)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 — 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 filesInstalling dependencies from lock file
Nothing to install, update or remove
Generating optimized autoload files___ 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_passsocket path doesn't match PHP-FPM'slistenpath, or PHP-FPM is stopped. Confirm the socket exists (ls /run/php/) and runsudo systemctl status php8.3-fpm. - The browser downloads your
index.phpinstead of running it — the web server has no PHP handler. You're missing thelocation ~ \.php$block (Nginx) orSetHandler proxy:...(Apache) that forwards.phpto PHP-FPM. - You deployed but the site still shows old code — OPcache is serving the cached bytecode. With
opcache.validate_timestamps=0you must runsudo systemctl reload php8.3-fpmat the end of every deploy. - "Class not found" only on the server — you ran
--no-devbut the class lives in arequire-devpackage, or you forgot to regenerate the autoloader. Runcomposer dump-autoload --optimizeand check the package is inrequire, notrequire-dev. - A visitor saw your database password in a stack trace —
display_errorswas leftOn. Set itOffandlog_errors = Onimmediately, then rotate the leaked secret.
Pro Tips
- 💡 Add a
.dockerignoreexcludingvendor/,.git/, andnode_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 || rollbackso a failed deploy reverts itself automatically. - 💡 Get free HTTPS with Let's Encrypt:
sudo certbot --nginx -d myapp.comissues and auto-renews a certificate. Never ship a production site on plain HTTP.
📋 Quick Reference — Deployment
| Task | Command / Setting | What It Does |
|---|---|---|
| Install (prod) | composer install --no-dev --optimize-autoloader | Prod deps + fast autoloader |
| Hide errors | display_errors = Off | Don't leak errors to visitors |
| Speed up PHP | opcache.enable = 1 | Compile each file once, reuse it |
| Go live | ln -sfn "$REL" current | Atomic, zero-downtime swap |
| Apply release | systemctl reload php8.3-fpm | Bust OPcache, load new code |
| Reload Nginx | nginx -t && systemctl reload nginx | Test config, then reload safely |
| Free HTTPS | certbot --nginx -d myapp.com | Issue + 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 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 herecurrent symlink atomically, then reload PHP-FPM. Confirm with curl -I returning 200.🎉 Lesson Complete!
- ✅ Nginx or Apache sits in front and forwards
.phpto PHP-FPM over a Unix socket - ✅ Production
php.inimeansdisplay_errors = Offandopcache.enable = 1 - ✅ Install with
composer install --no-dev --optimize-autoloader - ✅ Secrets live in a server-only
.envfile, 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.