DEV Community

Mufthi Ryanda
Mufthi Ryanda

Posted on

The Laravel 12 Docker Blueprint I Wish I Had: Nginx + PHP-FPM, Small Images, Clean CI/CD, and DigitalOcean Private Registry

This is the setup that turned my Laravel 12 project from “works on my machine” into “ready anytime”. The goal was simple: smaller builds, cleaner releases, and deployments that don’t need babysitting backed by CI/CD and a DigitalOcean private registry.

What you’ll learn

In this post, I’ll walk through the blueprint I used to:

  • Run Laravel 12 on PHP 8.2 in containers (the minimum supported version).
  • Serve the app with Nginx + PHP-FPM (clean separation of concerns).
  • Build smaller images and keep releases tidy (so shipping feels repeatable).
  • Push/pull images using a DigitalOcean private container registry (DOCR).
  • Automate it with clean CI/CD so deploys don’t need babysitting.

First we will create this Dockerfile.

# Multi-stage build
FROM composer:2.8 AS composer-stage

FROM php:8.3-fpm-alpine

# Install dependencies
RUN apk add --no-cache \
    nginx \
    supervisor \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    oniguruma-dev \
    libxml2-dev \
    libzip-dev \
    zip \
    unzip \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) pdo_mysql mbstring exif pcntl bcmath gd opcache zip

# Copy Composer
COPY --from=composer-stage /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# Copy composer files
COPY composer.json composer.lock ./

# Install production dependencies WITHOUT running scripts
RUN composer install \
    --no-interaction \
    --no-dev \
    --prefer-dist \
    --no-scripts \
    --no-autoloader

# Copy application
COPY . .

# Finish composer setup
RUN composer dump-autoload --optimize --classmap-authoritative --no-scripts

# Set permissions
RUN mkdir -p storage/logs storage/framework/{cache,sessions,views} bootstrap/cache \
    && touch database/database.sqlite \
    && chown -R www-data:www-data storage bootstrap/cache database/database.sqlite \
    && chmod -R 775 storage bootstrap/cache

# Nginx config
RUN echo 'server {' > /etc/nginx/http.d/default.conf && \
    echo '    listen 3000;' >> /etc/nginx/http.d/default.conf && \
    echo '    root /var/www/html/public;' >> /etc/nginx/http.d/default.conf && \
    echo '    index index.php;' >> /etc/nginx/http.d/default.conf && \
    echo '    location / {' >> /etc/nginx/http.d/default.conf && \
    echo '        try_files $uri $uri/ /index.php?$query_string;' >> /etc/nginx/http.d/default.conf && \
    echo '    }' >> /etc/nginx/http.d/default.conf && \
    echo '    location ~ \.php$ {' >> /etc/nginx/http.d/default.conf && \
    echo '        fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \
    echo '        fastcgi_index index.php;' >> /etc/nginx/http.d/default.conf && \
    echo '        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \
    echo '        include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \
    echo '    }' >> /etc/nginx/http.d/default.conf && \
    echo '}' >> /etc/nginx/http.d/default.conf

# Supervisor config
RUN echo '[supervisord]' > /etc/supervisord.conf && \
    echo 'nodaemon=true' >> /etc/supervisord.conf && \
    echo 'user=root' >> /etc/supervisord.conf && \
    echo '[program:php-fpm]' >> /etc/supervisord.conf && \
    echo 'command=php-fpm -F' >> /etc/supervisord.conf && \
    echo 'autostart=true' >> /etc/supervisord.conf && \
    echo 'autorestart=true' >> /etc/supervisord.conf && \
    echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \
    echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \
    echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \
    echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \
    echo '[program:nginx]' >> /etc/supervisord.conf && \
    echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \
    echo 'autostart=true' >> /etc/supervisord.conf && \
    echo 'autorestart=true' >> /etc/supervisord.conf && \
    echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \
    echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \
    echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \
    echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf

# Entrypoint script
RUN echo '#!/bin/sh' > /entrypoint.sh && \
    echo 'set -e' >> /entrypoint.sh && \
    echo 'if [ ! -f .env ]; then' >> /entrypoint.sh && \
    echo '  cp .env.example .env' >> /entrypoint.sh && \
    echo 'fi' >> /entrypoint.sh && \
    echo 'php artisan key:generate --force || true' >> /entrypoint.sh && \
    echo 'php artisan config:cache || true' >> /entrypoint.sh && \
    echo 'php artisan route:cache || true' >> /entrypoint.sh && \
    echo 'php artisan view:cache || true' >> /entrypoint.sh && \
    echo 'exec /usr/bin/supervisord -c /etc/supervisord.conf' >> /entrypoint.sh && \
    chmod +x /entrypoint.sh

EXPOSE 3000

ENTRYPOINT ["/entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Our Dockerfile is a multi stage Laravel image: it pulls in Composer from a lightweight builder stage, then runs on php-fpm (Alpine), installs Nginx + Supervisor + the PHP extensions Laravel commonly needs, installs production Composer deps (cached via composer.lock), copies the app, optimizes autoload, fixes Laravel storage/cache permissions, generates a basic Nginx vhost that serves /public and forwards PHP to FPM, and uses Supervisor to keep Nginx + PHP-FPM running together, Finally, the entrypoint ensures .env exists, warms up caches, then boots Supervisor and exposes port 3000.

Next, we will try to build it locally

docker build -t registry.digitalocean.com/hawkinstech/urecheapstore:latest .
Enter fullscreen mode Exit fullscreen mode
docker tag registry.digitalocean.com/hawkinstech/laravel-app:latest registry.digitalocean.com/hawkinstech/urecheapstore:latest
Enter fullscreen mode Exit fullscreen mode

Tag naming rules are strict (allowed characters, max length, etc.). DigitalOcean registry images follow this pattern: registry.digitalocean.com/<registry>/<image>:<tag>.

For local test, let's run our container.

docker run -d -p 3000:3000 --name urecheapstore registry.digitalocean.com/hawkinstech/urecheapstore:latest
Enter fullscreen mode Exit fullscreen mode

After it run perfectly on local, let's setup CI/CD

name: Build and Push to DigitalOcean Registry

on:
  push:
    branches:
      - master

env:
  REGISTRY: registry.digitalocean.com/hawkinstech
  IMAGE_NAME: urecheapstore

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

      - name: Log in to DigitalOcean Container Registry
        run: doctl registry login --expiry-seconds 600

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-to: type=inline

      - name: Verify image push
        run: |
          echo "Image pushed successfully:"
          echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
          echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
Enter fullscreen mode Exit fullscreen mode

This workflow turns every push to master into a fresh container release: it checks out your code, boots Docker Buildx (so builds are faster and cache friendly), installs doctl using your DIGITALOCEAN_ACCESS_TOKEN, then logs in to DigitalOcean Container Registry (DOCR) with short lived credentials. After that, it builds the image and pushes two tags—latest for “most recent deploy” and ${{ github.sha }} for an immutable, traceable version while reusing the previously pushed latest layer cache to speed up future builds.

Save this as .github/workflows/build-push.yml. On every push to master, it builds the Docker image and pushes it to registry.digitalocean.com/hawkinstech/urecheapstore with both latest and the commit SHA tag.

Wrap-up

At this point, you’ve got a repeatable flow: build the same Laravel image every time, ship it to DigitalOcean Container Registry, and let CI/CD do the boring work for you, so releases become predictable instead of stressful. The only “manual” part left is setting up the credentials once, and then you can forget about it and just push code.

If you want the missing setup steps, I split them into two short follow-ups:

  • Article 1: how to add DIGITALOCEAN_ACCESS_TOKEN as a GitHub Actions secret (Repo → Settings → Secrets and variables → Actions → New repository secret). (GitHub Docs)
  • Article 2: how to generate a DigitalOcean Personal Access Token (Control Panel → API → Personal access tokensGenerate New Token). (DigitalOcean Docs)

Bonus tip: the doctl registry login --expiry-seconds 600 approach is nice because it uses short-lived registry credentials during the workflow run. (DigitalOcean Docs)

Top comments (0)