CloudPloy

Docker Container Registries and CloudPloy

CloudPloy uses Docker containers to run your applications. Understanding how container images are built, stored, and deployed helps you optimize your deployment pipeline, use multi-stage builds effectively, and integrate with external container registries when your workflow requires it.

This page covers how CloudPloy handles Docker image builds, when and how to use external registries like GitHub Container Registry (GHCR), Docker Hub, and AWS ECR, Dockerfile best practices for PHP applications, and image tagging strategies for production deployments.

How CloudPloy Builds Container Images

When you deploy an application to CloudPloy, the build process follows these steps:

  1. CloudPloy clones your repository from GitHub at the specified branch or commit
  2. If a Dockerfile exists at the repository root, it's used to build the image
  3. If no Dockerfile is found, CloudPloy auto-detects the framework and uses a matching base image (WordPress on PHP-FPM, Laravel on PHP-FPM, Node.js on node:lts-alpine, etc.)
  4. The built image is stored internally and used to start your application container
  5. On the next deployment, a new image is built and blue-green switched in

For most applications, the auto-detected base image is sufficient. Add a custom Dockerfile when you need to install system dependencies, compile assets at build time, use multi-stage builds, or pin a specific PHP/Node.js version.

Writing Effective Dockerfiles for PHP Applications

Basic PHP-FPM Dockerfile

A minimal Dockerfile for a Laravel or Symfony application:

FROM php:8.3-fpm-alpine

# System dependencies
RUN apk add --no-cache \
    git \
    unzip \
    libpng-dev \
    libzip-dev \
    icu-dev

# PHP extensions
RUN docker-php-ext-install \
    pdo_mysql \
    zip \
    gd \
    intl \
    opcache

# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# Install PHP dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts

# Copy application code
COPY . .

# Run post-install scripts
RUN composer run-script post-install-cmd --no-interaction

# Cache Laravel config, routes, views
RUN php artisan config:cache && \
    php artisan route:cache && \
    php artisan view:cache

# Permissions
RUN chown -R www-data:www-data storage bootstrap/cache

Multi-Stage Build for Applications with Frontend Assets

Use a multi-stage build when you need Node.js to compile frontend assets but don't want Node.js in your final image:

##
## Stage 1: Build frontend assets
##
FROM node:20-alpine AS assets

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build

##
## Stage 2: PHP application
##
FROM php:8.3-fpm-alpine AS app

RUN apk add --no-cache git unzip libpng-dev libzip-dev icu-dev
RUN docker-php-ext-install pdo_mysql zip gd intl opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts

COPY . .
COPY --from=assets /app/public/build ./public/build

RUN composer run-script post-install-cmd --no-interaction && \
    php artisan config:cache && \
    php artisan route:cache && \
    php artisan view:cache

RUN chown -R www-data:www-data storage bootstrap/cache

Multi-stage builds keep the final image small - only the PHP runtime and compiled assets are included, not Node.js, npm packages, or build tools. A typical Laravel + Vite application drops from ~800MB to ~150MB with a multi-stage build.

WordPress Dockerfile

WordPress with custom plugins pre-installed:

FROM wordpress:6.7-php8.3-fpm-alpine

# Install WP-CLI
RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
    chmod +x wp-cli.phar && \
    mv wp-cli.phar /usr/local/bin/wp

# Install PHP extensions needed for plugins
RUN apk add --no-cache libzip-dev icu-dev && \
    docker-php-ext-install zip intl

# Add custom wp-config.php
COPY wp-config.php /var/www/html/wp-config.php

# Pre-install plugins (they'll be activated on first run via WP-CLI)
# Alternatively, use WP-CLI in deploy commands to install at deploy time

OPcache Configuration

Always configure OPcache in production PHP containers. Add this to your Dockerfile or mount as a config file:

# Create OPcache configuration
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.revalidate_freq=0" >> /usr/local/etc/php/conf.d/opcache.ini

Set opcache.validate_timestamps=0 in production - it prevents OPcache from checking whether PHP files have changed on disk, which adds latency per request. Since CloudPloy builds a new container image on every deployment, the cache is always fresh.

Using External Container Registries

By default, CloudPloy builds images from your source code on each deployment. For advanced workflows, you can pre-build images and push them to an external registry, then configure CloudPloy to pull from that registry instead of building from source.

This is useful when:

  • Your build process takes more than a few minutes and you want to cache layers in CI
  • Multiple applications share the same base image that should be built once
  • You need to run vulnerability scanning or compliance checks before deployment
  • Your team has an existing image build pipeline in GitHub Actions or GitLab CI

GitHub Container Registry (GHCR)

GHCR is free for public repositories and included with GitHub's plans for private repositories. Build and push your image in GitHub Actions, then deploy from GHCR to CloudPloy.

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Then in CloudPloy, configure your application to deploy from the GHCR image instead of building from source. Set the registry credentials in App > Settings > Environment Variables:

REGISTRY_URL=ghcr.io/your-org/your-app
REGISTRY_USERNAME=your-github-username
REGISTRY_PASSWORD=ghp_your_personal_access_token

Docker Hub

Docker Hub is the default public registry. Free accounts allow unlimited public images and one private image. For private PHP application images, use a paid Docker Hub plan or switch to GHCR which is included with GitHub.

# GitHub Actions: push to Docker Hub
- name: Log in to Docker Hub
  uses: docker/login-action@v3
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: yourdockerhubuser/yourapp:${{ github.sha }}

AWS Elastic Container Registry (ECR)

ECR is the right choice for teams already using AWS (ECS, EKS, Lambda, or SES). Costs ~$0.10/GB per month for storage.

# GitHub Actions: push to ECR
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: us-east-1

- name: Login to Amazon ECR
  id: login-ecr
  uses: aws-actions/amazon-ecr-login@v2

- name: Build and push
  env:
    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
  run: |
    docker build -t $ECR_REGISTRY/myapp:${{ github.sha }} .
    docker push $ECR_REGISTRY/myapp:${{ github.sha }}

Image Tagging Strategies

How you tag Docker images affects rollback capability, deployment traceability, and registry storage costs.

Tag Strategy Example Use Case Rollback
Git SHA myapp:a3f9e2d Production - exact traceability Easy - deploy any previous SHA
Semantic version myapp:1.4.2 Software with versioned releases Easy - deploy any previous version
Branch name myapp:main, myapp:staging Environment-specific deployments Limited - tag gets overwritten
latest myapp:latest Local development only None - unpredictable

Best practice: tag every production image with the Git commit SHA. Also apply a latest or branch tag for convenience in non-production environments. This gives you precise traceability ("which code is running in production?") without manual version bumping.

Tag Cleanup and Retention

Without a cleanup policy, registries accumulate thousands of old tags consuming storage. Configure lifecycle policies to keep only recent images:

# AWS ECR lifecycle policy (keep last 30 images)
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep last 30 images",
            "selection": {
                "tagStatus": "any",
                "countType": "imageCountMoreThan",
                "countNumber": 30
            },
            "action": { "type": "expire" }
        }
    ]
}

# GitHub Container Registry: set package retention in
# GitHub organization settings under Packages

Optimizing Docker Images for Faster Deployments

Layer Ordering and Caching

Docker builds images layer by layer and caches each layer. Layers only invalidate when their content changes. Order your Dockerfile from least-changed to most-changed to maximize cache hits:

# Slow (wrong order - code copy invalidates composer cache)
COPY . .
RUN composer install --no-dev --optimize-autoloader

# Fast (correct order - composer cache survives code changes)
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
COPY . .    # Only this layer and below rebuild when code changes

In a correctly ordered PHP Dockerfile, a code-only change (no composer.json change) only rebuilds the final COPY layer and anything after it - typically 5-10 seconds instead of 60-90 seconds for a full composer install.

Image Size Reduction

Smaller images pull faster on deployment. Common size reduction techniques:

# Use Alpine variants (smaller base OS)
FROM php:8.3-fpm-alpine    # ~90MB vs php:8.3-fpm at ~400MB

# Combine RUN commands to avoid extra layers
# Wrong:
RUN apk add --no-cache git
RUN apk add --no-cache unzip
RUN apk add --no-cache curl

# Right:
RUN apk add --no-cache git unzip curl

# Remove build-time dependencies in the same layer
RUN apk add --no-cache --virtual .build-deps gcc musl-dev && \
    docker-php-ext-install gd && \
    apk del .build-deps

# Use .dockerignore to exclude large directories
# .dockerignore file:
node_modules/
.git/
tests/
*.md
storage/logs/
storage/framework/cache/

Using BuildKit Cache Mounts

BuildKit (enabled by default in Docker 23+) supports cache mounts that persist between builds on the same build host:

# syntax=docker/dockerfile:1
FROM php:8.3-fpm-alpine

# Cache mount for apk packages (persists across builds)
RUN --mount=type=cache,target=/var/cache/apk \
    apk add --no-cache git unzip libpng-dev libzip-dev

# Cache mount for composer packages (persists across builds)
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/root/.composer \
    composer install --no-dev --optimize-autoloader --no-scripts

Cache mounts work on the build host but are not available when building in stateless CI environments (GitHub Actions runners are ephemeral). Use GitHub Actions' cache-from/cache-to: type=gha for CI-side caching instead.

Common Registry Workflows for Agencies

Shared Base Image

Agencies managing many client sites often build a custom base image with common dependencies pre-installed, then use it as the FROM for client-specific Dockerfiles:

# Build once: agency base image (ghcr.io/your-agency/php-base:8.3)
FROM php:8.3-fpm-alpine
RUN apk add --no-cache git unzip libpng-dev libzip-dev icu-dev
RUN docker-php-ext-install pdo_mysql zip gd intl opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# ... agency standard config

# Use in each client project
FROM ghcr.io/your-agency/php-base:8.3
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
COPY . .
RUN php artisan config:cache && php artisan route:cache

Updating the base image (e.g., patching a PHP extension) then triggers rebuilds of all client images on their next deployment, propagating the fix across the portfolio.

Environment-Specific Images

Build separate images for production and staging using build arguments:

# Dockerfile
ARG APP_ENV=production
ENV APP_ENV=$APP_ENV

RUN if [ "$APP_ENV" = "production" ]; then \
    composer install --no-dev --optimize-autoloader; \
    else \
    composer install --optimize-autoloader; \
    fi

# Build production image
docker build --build-arg APP_ENV=production -t myapp:prod .

# Build staging image (with dev dependencies for debugging)
docker build --build-arg APP_ENV=staging -t myapp:staging .

Security Scanning with Trivy

Before deploying to production, scan images for known vulnerabilities. Trivy is the most widely used open-source scanner and integrates easily into CI pipelines:

# GitHub Actions: scan before push
- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    format: 'table'
    exit-code: '1'              # Fail the workflow on CRITICAL findings
    ignore-unfixed: true         # Ignore unfixed CVEs (no patch available yet)
    vuln-type: 'os,library'
    severity: 'CRITICAL,HIGH'

Common sources of vulnerabilities in PHP images:

  • Outdated Alpine packages - update base image regularly
  • Outdated Composer dependencies - run composer audit in CI
  • PHP extensions with known CVEs - use --ignore-unfixed for vulnerabilities with no patch
  • Base image itself - pin to a specific digest, not just a tag

Pinning Base Image Digests for Reproducible Builds

Docker image tags like php:8.3-fpm-alpine are mutable - the registry can push a new image with the same tag. In security-sensitive environments, pin to an immutable digest:

# Get the digest of the current tag
docker pull php:8.3-fpm-alpine
docker inspect php:8.3-fpm-alpine --format '{{.RepoDigests}}'
# Output: [php@sha256:a1b2c3d4e5f6...]

# Dockerfile with pinned digest
FROM php@sha256:a1b2c3d4e5f6...

# Or use a specific version tag (more readable, but still mutable)
FROM php:8.3.2-fpm-alpine

Using the full image digest guarantees the same base layer on every build. The trade-off is that you must manually update the digest to get security patches in the base image. Most teams use specific minor version tags (8.3.2) as a compromise.


Questions about building Docker images for your CloudPloy application? Check App > Logs > Build Logs to see the output of your Dockerfile build, or read the Docker on CloudPloy guide for container runtime configuration. For multi-site workflows, see the multi-site management guide.