1. The LEMP Stack
LEMP — Linux, Nginx (pronounced "engine-x," the E), MySQL, PHP —
is the production version of every local environment built across this course.
The one real architectural shift from local development: PHP no longer serves
requests directly (as php -S localhost:8000 did in Week 1) — Nginx
sits in front, serving static files itself and forwarding only PHP requests to
PHP-FPM:
Local (Week 1): Browser → php -S (handles everything)
Production: Browser → Nginx → PHP-FPM (only for .php requests)
↳ serves static assets (CSS/JS/images) directly, no PHP involved
Static assets never touching PHP at all is a meaningful performance difference at scale — Nginx serving a CSS file directly is dramatically cheaper than routing it through a PHP worker process that does nothing but read and return the file unchanged.
2. Nginx + PHP-FPM Configuration
A working Nginx server block for a Laravel application:
server {
listen 80;
server_name task-tracker.example.com;
root /var/www/task-tracker/public; # Laravel's front controller lives here
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock; # hands off to PHP-FPM, per Week 26
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.(?!well-known).* {
deny all; # block access to .env, .git, and other dotfiles
}
}
try_files $uri $uri/ /index.php?$query_string; is what makes
Laravel's routing (Week 9) work at all in production — every request that doesn't
match a real static file falls through to index.php, Laravel's single
entry point, which then dispatches it through the router exactly as it did with
php artisan serve locally.
sudo ln -s /etc/nginx/sites-available/task-tracker /etc/nginx/sites-enabled/
sudo nginx -t # always test config before reloading
sudo systemctl reload nginx
3. Dockerizing Laravel
A multi-stage Dockerfile keeps the final image small — Composer's build-time dependencies never need to ship in the running container:
# Stage 1: install PHP dependencies
FROM composer:2 AS composer-build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Stage 2: the actual runtime image
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo pdo_mysql
WORKDIR /var/www
COPY --from=composer-build /app/vendor ./vendor
COPY . .
RUN php artisan config:cache && php artisan route:cache
EXPOSE 9000
CMD ["php-fpm"]
COPY --from=composer-build pulls only the resolved vendor/
directory from the first stage — the Composer binary and its own dependencies from
stage 1 never end up in the final image at all, keeping it meaningfully smaller.
config:cache/route:cache pre-compile Laravel's config and
routes into a single optimized file, skipping that work on every request in
production.
services:
app:
build: .
volumes:
- .:/var/www
depends_on: [db, redis]
nginx:
image: nginx:alpine
ports: ["8000:80"]
volumes:
- ./:/var/www
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on: [app]
db:
image: mysql:8
environment:
MYSQL_DATABASE: task_tracker
MYSQL_ROOT_PASSWORD: secret
volumes: ["db-data:/var/lib/mysql"]
redis:
image: redis:alpine
volumes:
db-data:
This mirrors production's Nginx + PHP-FPM split from earlier in this lesson, run locally in containers — the same architecture, same request path, testable before it ever touches a real server.
4. Deploying to a VPS
A minimal deploy script pulling together everything from this module — code, cached config, migrations, queue worker, OPcache:
#!/bin/bash
set -e # stop immediately on any failing command
cd /var/www/task-tracker
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo systemctl restart php8.3-fpm # clears OPcache -- per Week 26
sudo supervisorctl restart task-tracker-worker:* # restarts the queue worker -- per Week 16
set -e matters: without it, a failed composer install
wouldn't stop the script, and php artisan migrate could run against
a half-updated codebase. Restarting PHP-FPM is the OPcache-clearing step flagged
in Week 26 — skip it, and the server keeps serving the previous deploy's cached
bytecode indefinitely.
[program:task-tracker-worker]
command=php /var/www/task-tracker/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=2
Supervisor keeps the queue worker from Week 16 running continuously, restarting it
automatically if it crashes — queue:work run manually in a terminal
stops the moment that terminal closes, which is exactly wrong for production.
5. Deploying WordPress
WordPress's deployment story is simpler in one respect (no artisan
build steps) and requires more discipline in another (theme/plugin file changes
need explicit version control, since WordPress itself doesn't enforce it):
# Version-control only your custom theme/plugin, not WordPress core itself
git clone your-repo /var/www/site/wp-content/themes/my-theme
git clone your-portfolio-manager-repo /var/www/site/wp-content/plugins/portfolio-manager
wp core update # keep WordPress core current
wp plugin update --all # keep third-party plugins current -- ties directly into Week 25's audits
wp cache flush
A key discipline worth internalizing from this course's structure: WordPress core and third-party plugins are typically not committed to your own repository (they're external dependencies, updated independently, much like Composer packages) — only the custom theme and plugin you actually wrote belong in version control, deployed the same way any other codebase from this course would be.
6. Hands-on Exercise
Dockerize and deploy the task tracker
A real, working deployment — locally in Docker first, then to an actual VPS.
Requirements:
- Write the multi-stage
Dockerfileanddocker-compose.ymlabove, and confirmdocker compose upserves the full app locally through Nginx. - Provision a VPS (a $5-6/month tier from any provider is enough), install Nginx, PHP-FPM and MySQL, and write the Nginx server block above for it.
- Write
deploy.shwithset -e, and confirm it runs cleanly end to end against the VPS. - Configure Supervisor to keep the queue worker running, and confirm it auto-restarts after a deliberate
killof the worker process. - Deploy WordPress to the same or a second VPS, with your Portfolio Manager plugin and custom theme pulled from their own Git repositories.
Deploy a small, deliberate code change end to end and confirm it's actually live before considering this exercise done — a deploy script that "should work" isn't the same as one you've watched successfully ship a real change.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does Nginx serve static assets directly instead of forwarding every request to PHP-FPM?
Why does Nginx serve static assets directly instead of forwarding every request to PHP-FPM?
A PHP worker process spinning up just to read and return an unchanged CSS or image file is pure overhead — Nginx can serve that same file directly, far more cheaply, with no PHP involved at all. Routing only .php requests to PHP-FPM (via the location ~ \.php$ block) means PHP workers are reserved for work that actually needs PHP to run.
Q2
Why does the Dockerfile use a separate composer-build stage instead of running composer install directly in the final image?
Why does the Dockerfile use a separate composer-build stage instead of running composer install directly in the final image?
The multi-stage build lets only the final, resolved vendor/ directory get copied into the runtime image — the Composer binary itself and any build-time tooling from the first stage never end up in the image that actually ships and runs in production, keeping it meaningfully smaller and reducing its attack surface.
Q3
Why does deploy.sh restart PHP-FPM after every deploy?
Why does deploy.sh restart PHP-FPM after every deploy?
With opcache.validate_timestamps = 0 (Week 26's recommended production setting), OPcache never re-checks whether a file has changed on disk — it keeps serving the previously cached, compiled bytecode indefinitely. Restarting PHP-FPM clears that cache, forcing the newly deployed code to actually take effect; skipping this step means the server silently keeps running the old version despite the successful git pull.
Q4
Why run the queue worker under Supervisor instead of just typing php artisan queue:work in a terminal on the server?
Why run the queue worker under Supervisor instead of just typing php artisan queue:work in a terminal on the server?
A worker run manually in a terminal session stops the moment that session ends (closing the SSH connection, a server reboot, the process crashing on an unhandled error) — there's nothing to bring it back. Supervisor keeps it running continuously and automatically restarts it if it ever crashes, which is what turns Week 16's queued jobs from something that only works while a developer happens to have a terminal open into a genuinely reliable production feature.