πŸš€ Docker in Production: Automated Deployment with GitHub Actions

πŸš€ 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

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 domain
  • SSH_USER: deployer
  • SSH_KEY: The private SSH key generated above
  • SSH_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

  1. 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
  1. 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;"]
  1. 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
  1. 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
Comments (0)
Leave a comment

Β© 2025 All rights reserved.