π Docker in Production: Automated Deployment with GitHub Actions
This comprehensive guide will transform your Laravel deployment process into a smooth, automated pipeline using Docker and GitHub Actions. Learn how to set up a bulletproof production environment that scales with your needs.
π Table of Contents
- Create User
- Initial Server Setup
- Set Up SSH Key for GitHub Actions
- Add GitHub Secrets
- Application Setup
- Troubleshooting
- Conclusion
0. Create User (optionalβyou can use your non-root user)
# Create user with proper primary group
sudo adduser deployer --ingroup www-data
sudo usermod -aG sudo deployer
1. Initial Server Setup
# Install Docker and Docker Compose
curl -fsSL https://get.docker.com | sudo sh
# Add user to Docker group
sudo usermod -aG docker deployer
2. Set Up SSH Key for GitHub Actions (as deployer user)
# Create SSH directory
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Generate SSH key
ssh-keygen -t rsa -b 4096 -C "github-actions-deploy"
# Add public key to authorized_keys
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
# Display the private key
cat ~/.ssh/id_rsa
3. Add GitHub Secrets
Add the following secrets to your GitHub repository:
SSH_HOST
: Your VPS IP address or domainSSH_USER
: deployerSSH_KEY
: The private SSH key generated aboveSSH_PORT
: 22 (or your custom SSH port)
Add variable for .env production file:
ENV_FILE
: The contents of your .env file
4. Application Setup
Create the following files in your Laravel project:
4.1 Docker Configuration Files
- Create
docker/php/Dockerfile
:
FROM dommin/php-8.4-fpm-alpine:latest
USER root
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
RUN sed -i 's/user = www-data/user = appuser/g' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's/group = www-data/group = appuser/g' /usr/local/etc/php-fpm.d/www.conf
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist --no-scripts
COPY --chown=appuser:appuser . .
COPY --chown=appuser:appuser --from=node /var/www/public/build /var/www/public/build
COPY --chown=appuser:appuser docker/supervisord.conf /etc/supervisord.conf
COPY --chown=appuser:appuser docker/php/php.ini /usr/local/etc/php/php.ini
COPY --chown=appuser:appuser docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf
RUN mkdir -p \
storage/framework/{cache,sessions,views} \
storage/app/public \
public/storage \
bootstrap/cache \
storage/logs \
/var/log/supervisor \
/var/run/php \
/var/log/php-fpm \
/home/appuser/.cache/puppeteer \
&& chown -R appuser:appuser \
storage \
bootstrap/cache \
public \
/var/log/supervisor \
/var/run/php \
/var/log/php-fpm \
/home/appuser/.cache/puppeteer \
&& chmod -R 775 storage bootstrap/cache
RUN ln -s /var/www/storage/app/public /var/www/public/storage
USER appuser
EXPOSE 9000
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
1.1 Create docker/supervisord.conf
:
[supervisord]
nodaemon=true
logfile=/var/www/storage/logs/supervisord.log
pidfile=/var/run/supervisord.pid
user=appuser
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
user=appuser
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[program:horizon]
command=php /var/www/artisan horizon
user=appuser
autostart=true
autorestart=true
stdout_logfile=/var/www/storage/logs/horizon.log
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stopwaitsecs=3600
[program:scheduler]
command=php /var/www/artisan schedule:work
user=appuser
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[program:reverb]
command=php /var/www/artisan reverb:start --debug
user=appuser
autostart=true
autorestart=true
stdout_logfile=/var/www/storage/logs/reverb.log
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
stopwaitsecs=3600
1.3 Create docker/php/php.ini
:
[PHP]
expose_php = Off
max_execution_time = 120
max_input_time = 120
memory_limit = 256M
[opcache]
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.enable_cli=0
[File Uploads]
upload_max_filesize = 64M
post_max_size = 64M
max_file_uploads = 20
[Date]
date.timezone = Europe/Warsaw
[Error]
display_errors = On
display_startup_errors = On
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
error_log = /var/www/storage/logs/php-error.log
1.4 Create docker/php/www.conf
:
[www]
user = www-data
group = www-data
listen = 0.0.0.0:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.status_path = /status
access.log = /proc/self/fd/1
catch_workers_output = yes
decorate_workers_output = no
- Create
docker/nginx/Dockerfile
:
FROM nginx:stable-alpine
# Declare build arguments
ARG HTPASSWD_USER
ARG HTPASSWD_PASS
# Create necessary directories first
RUN mkdir -p /var/www/public
COPY --from=node /var/www/public/build /var/www/public/build
COPY public/*.php public/*.txt public/*.ico /var/www/public/
COPY docker/nginx/conf.d /etc/nginx/conf.d
# Create .htpasswd file for basic auth (optional)
RUN if [ -n "$HTPASSWD_USER" ] && [ -n "$HTPASSWD_PASS" ]; then \
apk add --no-cache apache2-utils && \
htpasswd -b -c /etc/nginx/.htpasswd "$HTPASSWD_USER" "$HTPASSWD_PASS"; \
fi
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
- Create
docker/node/Dockerfile
FROM node:22-alpine
WORKDIR /var/www
COPY package*.json ./
RUN npm ci
COPY vite.config.ts tsconfig.json ./
COPY docker/node/.env .env
COPY . .
RUN npm run build
- Create
docker-compose.production.yml
:
services:
app:
image: ${REGISTRY}/${PHP_IMAGE_NAME}:${TAG:-latest}
container_name: laravel_app
restart: unless-stopped
working_dir: /var/www
env_file: .env
volumes:
- laravel_storage:/var/www/storage
- ./.env:/var/www/.env
ports:
- '9000:9000'
- '8080:8080'
networks:
- laravel_network
depends_on:
- redis
nginx:
image: ${REGISTRY}/${NGINX_IMAGE_NAME}:${TAG:-latest}
container_name: laravel_nginx
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- laravel_storage:/var/www/storage
networks:
- laravel_network
depends_on:
- app
redis:
image: redis:alpine
container_name: laravel_redis
restart: unless-stopped
networks:
- laravel_network
volumes:
- redis_data:/data
networks:
laravel_network:
driver: bridge
volumes:
laravel_storage:
redis_data:
4.2 GitHub Actions Workflow
Create .github/workflows/workflow.yml
:
name: π Build, Push and Deploy
on:
push:
branches:
- main
env:
REGISTRY: ghcr.io
NODE_IMAGE_NAME: dommmin/laravel-production-node-builder
PHP_IMAGE_NAME: dommmin/laravel-production-php
NGINX_IMAGE_NAME: dommmin/laravel-production-nginx
DOCKER_BUILDKIT: 1
jobs:
build:
name: ποΈ Build and Push Images
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: ${{ runner.os }}-buildx-
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create .env files
run: |
mkdir -p docker/node docker/php
echo "${{ vars.ENV_FILE }}" > docker/node/.env
echo "${{ vars.ENV_FILE }}" > docker/php/.env
cat docker/node/.env
- name: Build and push Node builder image
uses: docker/build-push-action@v5
with:
context: .
file: docker/node/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.NODE_IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
- name: Build and push PHP image
uses: docker/build-push-action@v5
with:
context: .
file: docker/php/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.PHP_IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
build-contexts: |
node=docker-image://${{ env.REGISTRY }}/${{ env.NODE_IMAGE_NAME }}:latest
- name: Build and push Nginx image
uses: docker/build-push-action@v5
with:
context: .
file: docker/nginx/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
build-contexts: |
node=docker-image://${{ env.REGISTRY }}/${{ env.NODE_IMAGE_NAME }}:latest
build-args: |
HTPASSWD_USER=${{ secrets.HTPASSWD_USER }}
HTPASSWD_PASS=${{ secrets.HTPASSWD_PASS }}
deploy:
name: π Deploy to Production
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4 # Critical for accessing files!
- name: Setup SSH Authentication
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: Configure known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Prepare environment file
run: |
echo "${{ vars.ENV_FILE }}" > .env
{
echo "REGISTRY=${{ env.REGISTRY }}"
echo "PHP_IMAGE_NAME=${{ env.PHP_IMAGE_NAME }}"
echo "NGINX_IMAGE_NAME=${{ env.NGINX_IMAGE_NAME }}"
echo "TAG=latest"
} >> .env
- name: Transfer deployment files
run: |
scp -P ${{ secrets.SSH_PORT }} \
docker-compose.production.yml \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:~/laravel/docker-compose.yml
scp -P ${{ secrets.SSH_PORT }} \
.env deploy.sh \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:~/laravel/
- name: Trigger deployment script
run: |
ssh -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
"cd ~/laravel && chmod +x deploy.sh && ./deploy.sh"
4.3 Deployment Script
Create deploy.sh
:
#!/bin/bash
# Fail immediately if any command fails
set -eo pipefail
# Deployment header
echo "π Starting production deployment..."
echo "π $(date)"
# Pull latest images
echo "π₯ Pulling updated Docker images..."
docker compose pull
# Stop existing containers if running
echo "π Stopping existing containers..."
docker compose down --remove-orphans
# Start fresh containers
echo "π Starting new containers..."
docker compose up -d --force-recreate
# Run application maintenance
echo "π§ Running application maintenance tasks..."
docker compose exec -T app php artisan optimize:clear
docker compose exec -T app php artisan optimize
docker compose exec -T app php artisan storage:link
docker compose exec -T app php artisan migrate --force
# Cleanup old Docker objects
echo "π§Ή Cleaning up unused Docker resources..."
docker system prune --volumes -f
# Success message
echo "β
Deployment completed successfully!"
echo "π $(date)"
4.4 Directory Structure
Your Laravel project should have the following structure:
.
βββ .github
β βββ workflows
β βββ workflow.yml
βββ docker
β βββ node
β β βββ Dockerfile
β βββ nginx
β β βββ Dockerfile
β β βββ conf.d
β β βββ default.conf
β βββ php
β βββ Dockerfile
β βββ php.ini
β βββ supervisord.conf
β βββ www.conf
βββ docker-compose.production.yml
βββ deploy.sh
βββ ... (other Laravel files)
Troubleshooting
-
Permission Issues:
# Check Docker access docker ps
-
Docker Issues:
# Check Docker status sudo systemctl status docker
-
Deployment Failures: Check the GitHub Actions logs for detailed error messages.
Conclusion
After completing these steps, your server will be ready for Docker-based Laravel deployment. The setup ensures:
- Proper permissions for Docker and Laravel
- Secure SSH access for GitHub Actions
- Persistent storage for Laravel data
- Security best practices implementation