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"]
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 .
docker tag registry.digitalocean.com/hawkinstech/laravel-app:latest registry.digitalocean.com/hawkinstech/urecheapstore:latest
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
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 }}"
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_TOKENas 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 tokens → Generate 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)