In this post, I’ll show you how I set up a CI/CD pipeline for my Slim-PHP app using Docker, GitHub Actions, and Caddy. The goal: every push to main deploys the app automatically to my DigitalOcean server — with near-zero downtime.

✅ Live at: aabillify.com


💡 Why I Built This

Manually deploying updates was slow and error-prone. I wanted an automated flow where every code push:

  • Builds the app,

  • Pushes a Docker image, and

  • Deploys it to the server with minimal downtime.

I chose:

  • Slim-PHP for a fast, lightweight backend,

  • Caddy for its easy setup and automatic HTTPS,

  • GitHub Actions for seamless CI/CD.

🧱 Project Overview

My app has:

  • A Slim-PHP backend

  • A FrankenPHP container for local dev

  • A Caddy container for HTTPS in production

Folder Structure:

#Folder Structure
/my-app
  ├── public/
  ├── src/
  ├── Dockerfile
  ├── docker-compose.yml
  └── Caddyfile

🐳 Docker Setup

Dockerfile (uses FrankenPHP)

FROM dunglas/frankenphp:php8.4.3-alpine

# Set working directory
WORKDIR /app/public/www

# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Install dependencies: Composer and PHP extensions
RUN install-php-extensions \
    pdo_mysql \
    gd \
    intl \
    zip \
    opcache \
    curl \ 
    json \
    mbstring \
    pdo \
    openssl \
    tokenizer \
    fileinfo

COPY . .

# Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader

# Set correct permissions for the web server
RUN chown -R www-data:www-data /app/public/www

# Configure entry point with FrankenPHP
CMD ["frankenphp", "run", "--config", "Caddyfile"]

Caddyfile (for local dev)

{
  debug

  frankenphp {
    watch /app/public/www/
  }
}
# handles http
:80 {
  root * /app/public/www/public
  php_server
  file_server
}

docker-compose.yml

services:
  php:
    container_name: local-slim-app
    build: .
    networks:
      - slim-net
    ports:
      - "${PORT}:80" # HTTP 
    volumes:
      - ./:/app/public/www
      - caddy_data:/data
      - caddy_config:/config
    tty: true

# Volumes needed for Caddy certificates and configuration
volumes:
  caddy_data:
  caddy_config:
# The network slim-net must be created in server
networks:
  slim-net:
    external: true

⚙️ GitHub Actions Workflow

Each push to main triggers:

  • Dependency install

  • Vite asset build

  • Docker image build

  • Push to GitHub Container Registry (GHCR)

  • SSH into the server and deploy

  • Cleanup old images

.github/workflows/deploy.yml

name: Deploy Slim-app

on:
  push:
    branches:
      - main

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

    steps:
      - name: 🛎️ Checkout code
        uses: actions/checkout@v3

      - name: 🔧 Set up Node.js
        uses: actions/setup-node@v3
        with: 
          node-version: 22

      - name: 📦 Install dependencies
        run: npm ci

      - name: 🛠️ Build Vite assets
        run: npm run build

      - name: 🐳 Build Docker image
        run: docker build -t ghcr.io/${{secrets.USER}}/slim-app:latest .

      - name: 🔐 Log in to GitHub Container Registry
        run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{secrets.USER}} --password-stdin

      - name: 🚀 Push Docker image to GitHub Container Registry
        run: docker push ghcr.io/${{secrets.USER}}/slim-app:latest

      - name: 🪂 Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{secrets.SERVER_IP}}
          username: root
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{secrets.USER}} --password-stdin
            docker pull ghcr.io/${{secrets.USER}}/slim-app:latest

            # Run new container on a test port
            bash /docker/deploy.sh

            # delete other images
            docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
              | grep 'ghcr.io/${{secrets.USER}}/slim-app' \
              | grep -v 'latest' \
              | awk '{print $2}' \
              | xargs -r docker rmi

      - name: 🧹 Cleanup old images
        run: |
          sudo apt-get install jq

          # List all image versions
          curl -s -H "Authorization: token ${{ secrets.PACKAGE_TOKEN }}" \
            https://api.github.com/users/${{secrets.USER}}/packages/container/slim-app/versions \
            | jq '.[].id' \
            | tail -n +4 \
            | xargs -I {} curl -X DELETE -H "Authorization: token ${{ secrets.PACKAGE_TOKEN }}" \
              https://api.github.com/users/${{secrets.USER}}/packages/container/slim-app/versions/{}

🖥️ Server Setup

On the server:

  • Installed Docker and SSH keys
  • Created Docker network: slim-net
  • Created a deploy.sh script to:
    • Start a new container on an alternate port
    • Wait for it to become healthy
    • Swap it in Caddy’s config
    • Remove the old container

deploy.sh (zero-downtime logic)

#!/bin/bash

PORT=8085
OTHER_PORT=8086
...

# Determine available port
if docker ps | grep ":$PORT->"; then
  PORT=$OTHER_PORT
  USED_PORT=8085
else
  USED_PORT=$OTHER_PORT
fi

...

# Run new container with generated compose file
envsubst < /docker/slim-app/docker-compose.yml.template > "/docker/slim-app/docker-compose-php$PORT.yml"
docker-compose -f "/docker/slim-app/docker-compose-php$PORT.yml" up -d

# Wait for health, update Caddy config, and swap
...

🗂️ Server Folder Structure

/docker
  ├── caddy/
        ├── docker-compose.yml
        ├── Caddyfile.template
        └── Caddyfile
  ├── deploy.sh
  ├── slim-app/
        ├── docker-compose-phpXXXX.yml
        └── docker-compose.yml.template

docker-compose.yml.template

#a docker-compose-phpXXXX.yml will be generated by this template in deploy.sh

services:
  ${SERVICE_NAME}:
    image: ghcr.io/aabill/slim-app:latest
    container_name: ${CONTAINER_NAME}
    networks:
      - slim-net
    ports:
      - "${PORT}:80" # HTTP
    volumes:
      - ./.env:/app/public/www/.env
    environment:
      - SITE_ADDRESS=http://localhost:80 
      - SUB_SITE_ADDRESS=http://subdomain.localhost:80
      - APP_URL="http://localhost:80"

networks:
  slim-net:
    external: true

caddy/docker-compose.yml

## Run the caddy container once:
## `/docker/caddy docker-compose up -d`

services:
  caddy:
    image: caddy:alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile.template:/etc/caddy/Caddyfile.template
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - slim-net
    command: ["caddy", "run", "--watch", "--config", "/etc/caddy/Caddyfile"]

volumes:
  caddy_data:
  caddy_config:

networks:
  slim-net:
    external: true

Caddyfile.template

#a Caddyfile will be generated by this template in deploy.sh

yourdomain.com {
  redir https://www.yourdomain.com{uri} 301
}

www.yourdomain.com {
    reverse_proxy ${CONTAINER_NAME}:80
}

✅ Final Thoughts

With this setup:

  • Every push goes live automatically
  • The deployment is resilient and fast
  • You avoid downtime by switching containers smartly