Week 28: Deployment — LAMP/LEMP & Docker

Every project this course has built has run locally. This week ships one — the real Nginx + PHP-FPM setup previewed back in Week 1, a proper multi-stage Docker image for the Laravel app, and the concrete steps for getting both the Laravel API and the WordPress site onto a VPS.

Module 25 of 28 Week 28 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Configure Nginx + PHP-FPM correctly for a production PHP app
  • Build a multi-stage Docker image for a Laravel application
  • Deploy both Laravel & WordPress to a VPS with a real process manager

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:

the request path, production vs. local
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:

/etc/nginx/sites-available/task-tracker
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.

terminal — enabling it
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:

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

docker-compose.yml — the full local stack
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:

deploy.sh
#!/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.

/etc/supervisor/conf.d/task-tracker-worker.conf
[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):

a minimal deploy approach
# 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

Hands-on

Dockerize and deploy the task tracker

A real, working deployment — locally in Docker first, then to an actual VPS.

Requirements:

  1. Write the multi-stage Dockerfile and docker-compose.yml above, and confirm docker compose up serves the full app locally through Nginx.
  2. 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.
  3. Write deploy.sh with set -e, and confirm it runs cleanly end to end against the VPS.
  4. Configure Supervisor to keep the queue worker running, and confirm it auto-restarts after a deliberate kill of the worker process.
  5. Deploy WordPress to the same or a second VPS, with your Portfolio Manager plugin and custom theme pulled from their own Git repositories.
Hint

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?

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?

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?

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?

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.