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