<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Aaron Bill Domingo</title>
    <description>The latest articles on Forem by Aaron Bill Domingo (@aabill).</description>
    <link>https://forem.com/aabill</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F707339%2F9246c8f6-9754-42fc-82d9-35b6c388eb19.jpeg</url>
      <title>Forem: Aaron Bill Domingo</title>
      <link>https://forem.com/aabill</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/aabill"/>
    <language>en</language>
    <item>
      <title>🚀 Auto-Deploying a Slim-PHP App with Docker, GitHub Actions, and Caddy</title>
      <dc:creator>Aaron Bill Domingo</dc:creator>
      <pubDate>Fri, 02 May 2025 01:39:29 +0000</pubDate>
      <link>https://forem.com/aabill/how-i-created-a-cicd-pipeline-for-deploying-a-dockerized-slim-php-app-with-caddy-nib</link>
      <guid>https://forem.com/aabill/how-i-created-a-cicd-pipeline-for-deploying-a-dockerized-slim-php-app-with-caddy-nib</guid>
      <description>&lt;p&gt;In this post, I’ll show you how I set up a CI/CD pipeline for my &lt;a href="https://www.slimframework.com/" rel="noopener noreferrer"&gt;Slim-PHP&lt;/a&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ Live at: &lt;a href="http://aabillify.com" rel="noopener noreferrer"&gt;aabillify.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💡 Why I Built This
&lt;/h2&gt;

&lt;p&gt;Manually deploying updates was slow and error-prone. I wanted an automated flow where every code push:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Builds the app,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pushes a Docker image, and&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deploys it to the server with minimal downtime.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Slim-PHP&lt;/strong&gt; for a fast, lightweight backend,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Caddy&lt;/strong&gt; for its easy setup and automatic HTTPS,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; for seamless CI/CD.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🧱 Project Overview
&lt;/h2&gt;

&lt;p&gt;My app has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Slim-PHP backend&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A FrankenPHP container for local dev&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A Caddy container for HTTPS in production&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Folder Structure:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#Folder Structure
/my-app
  ├── public/
  ├── src/
  ├── Dockerfile
  ├── docker-compose.yml
  └── Caddyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🐳 Docker Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Dockerfile&lt;/code&gt; (uses FrankenPHP)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;Caddyfile&lt;/code&gt; (for local dev)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  debug

  frankenphp {
    watch /app/public/www/
  }
}
# handles http
:80 {
  root * /app/public/www/public
  php_server
  file_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;docker-compose.yml&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  ⚙️ GitHub Actions Workflow
&lt;/h2&gt;

&lt;p&gt;Each push to main triggers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Dependency install&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vite asset build&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Docker image build&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Push to GitHub Container Registry (GHCR)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SSH into the server and deploy&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cleanup old images&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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/ssh-action@v1.0.3
        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/{}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🖥️ Server Setup
&lt;/h2&gt;

&lt;p&gt;On the server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installed Docker and SSH keys&lt;/li&gt;
&lt;li&gt;Created Docker network: slim-net&lt;/li&gt;
&lt;li&gt;Created a deploy.sh script to:

&lt;ul&gt;
&lt;li&gt;Start a new container on an alternate port&lt;/li&gt;
&lt;li&gt;Wait for it to become healthy&lt;/li&gt;
&lt;li&gt;Swap it in Caddy’s config&lt;/li&gt;
&lt;li&gt;Remove the old container&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;deploy.sh&lt;/code&gt; (zero-downtime logic)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/bash

PORT=8085
OTHER_PORT=8086
...

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

...

# Run new container with generated compose file
envsubst &amp;lt; /docker/slim-app/docker-compose.yml.template &amp;gt; "/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
...


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🗂️ Server Folder Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/docker
  ├── caddy/
        ├── docker-compose.yml
        ├── Caddyfile.template
        └── Caddyfile
  ├── deploy.sh
  ├── slim-app/
        ├── docker-compose-phpXXXX.yml
        └── docker-compose.yml.template
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;docker-compose.yml.template&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;caddy/docker-compose.yml&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## 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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;Caddyfile.template&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#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
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ✅ Final Thoughts
&lt;/h2&gt;

&lt;p&gt;With this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every push goes live automatically&lt;/li&gt;
&lt;li&gt;The deployment is resilient and fast&lt;/li&gt;
&lt;li&gt;You avoid downtime by switching containers smartly&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>cicd</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
