DEPLOY, WITHOUT THE GUESSWORK

How to Deploy Laravel to a VPS (Docker + PHP-FPM + HTTPS)

Deploy Laravel applications to a VPS with PHP-FPM, Nginx, MySQL, and Redis. Production-ready with queues, scheduler, and HTTPS.

How to Deploy Laravel to a VPS (Docker + PHP-FPM + HTTPS)

You are going to deploy a Laravel application to a VPS:

This is production-grade Laravel hosting on your own VPS.

What you will have at the end

Step 1: Prepare your Laravel project

Make sure your .env.example has all required variables:

APP_NAME=MyApp
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.com

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=

REDIS_HOST=redis
REDIS_PORT=6379

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

Step 2: Create the Dockerfile

Create Dockerfile:

FROM php:8.3-fpm-alpine AS base

# Install system dependencies
RUN apk add --no-cache \
    git \
    curl \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    libzip-dev \
    zip \
    unzip \
    mysql-client \
    oniguruma-dev

# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip opcache

# Install Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps

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

WORKDIR /var/www/html

# Production stage
FROM base AS production

# Copy application
COPY . .

# Install dependencies (no dev)
RUN composer install --no-dev --optimize-autoloader --no-interaction

# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

# Optimize Laravel
RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache

USER www-data

EXPOSE 9000
CMD ["php-fpm"]

Create nginx.conf:

server {
    listen 80;
    server_name _;
    root /var/www/html/public;
    index index.php;

    charset utf-8;

    # Gzip
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    # Static files
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public";
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Step 3: Create docker-compose.yml

services:
  traefik:
    image: traefik:v2.11
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./public:/var/www/html/public:ro
    depends_on:
      - app
    labels:
      - traefik.enable=true
      - traefik.http.routers.laravel.rule=Host(`${APP_DOMAIN}`)
      - traefik.http.routers.laravel.entrypoints=websecure
      - traefik.http.routers.laravel.tls=true
      - traefik.http.routers.laravel.tls.certresolver=letsencrypt
      - traefik.http.services.laravel.loadbalancer.server.port=80

  app:
    build:
      context: .
      target: production
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - ./storage:/var/www/html/storage
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  queue:
    build:
      context: .
      target: production
    restart: unless-stopped
    command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
    env_file:
      - .env
    volumes:
      - ./storage:/var/www/html/storage
    depends_on:
      - app
      - redis

  scheduler:
    build:
      context: .
      target: production
    restart: unless-stopped
    command: sh -c "while true; do php artisan schedule:run --verbose; sleep 60; done"
    env_file:
      - .env
    volumes:
      - ./storage:/var/www/html/storage
    depends_on:
      - app

  db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data

volumes:
  letsencrypt:
  mysql_data:
  redis_data:

Step 4: Set up environment

Create .env on your VPS:

APP_NAME=MyApp
APP_ENV=production
APP_KEY=base64:your-key-here
APP_DEBUG=false
APP_URL=https://your-domain.com
APP_DOMAIN=your-domain.com

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=strong-password-here
DB_ROOT_PASSWORD=root-password-here

REDIS_HOST=redis
REDIS_PORT=6379

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

[email protected]

Generate an app key locally and copy it:

php artisan key:generate --show

Step 5: Deploy

# Create storage directories with correct permissions
mkdir -p storage/framework/{sessions,views,cache}
mkdir -p storage/logs
chmod -R 775 storage bootstrap/cache

# Start the stack
docker compose up -d --build

# Run migrations
docker compose exec app php artisan migrate --force

# Clear caches (first deploy)
docker compose exec app php artisan optimize:clear
docker compose exec app php artisan optimize

Verify everything is running:

docker compose ps
docker compose logs -f app

Step 6: Maintenance commands

Common artisan commands through Docker:

# Run migrations
docker compose exec app php artisan migrate

# Clear all caches
docker compose exec app php artisan optimize:clear

# Rebuild caches
docker compose exec app php artisan optimize

# Tinker
docker compose exec app php artisan tinker

# Queue restart (after code changes)
docker compose exec app php artisan queue:restart

Database backup script (backup.sh):

#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=~/backups/laravel

mkdir -p $BACKUP_DIR

docker compose exec -T db mysqldump -u laravel -p'your-password' laravel | gzip > $BACKUP_DIR/db_$DATE.sql.gz

find $BACKUP_DIR -mtime +7 -delete
echo "Backup completed: $BACKUP_DIR/db_$DATE.sql.gz"

Troubleshooting

Storage permission errors

Laravel needs write access to storage:

# Fix permissions
docker compose exec app chown -R www-data:www-data /var/www/html/storage
docker compose exec app chmod -R 775 /var/www/html/storage

# Or from host
sudo chown -R 82:82 storage  # 82 is www-data in Alpine

Queue jobs not processing

Check the queue worker logs:

docker compose logs -f queue

Common issues:

MySQL connection refused

Wait for the health check to pass:

docker compose ps  # Check db health status

# Test connection manually
docker compose exec db mysql -u laravel -p -e "SELECT 1;"

Slow first request after deploy

Laravel caches are cold. Run optimization:

docker compose exec app php artisan optimize
docker compose exec app php artisan view:cache

Internal links (recommended next reads)


Related in the StoicSoft network

If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, 1FileTool is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device.

From across the StoicSoft network

Hand-curated reads on the same topic from sister sites in the StoicSoft family.