Part of the DigitalOcean deploy series. Tested on Laravel 13, PHP 8.4, Ubuntu 24.04, June 2026.

The server is ready, so now we put the app on it, serve it over HTTPS, run the queue and scheduler, and set up automatic deploys. Make sure your domain already resolves to the droplet, because Caddy needs that to fetch a certificate. Work as deploy.

A GitHub deploy key

Make a key on the droplet so it can pull your private repo, read only.

ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""

Tell git to use it for GitHub by adding a block to ~/.ssh/config.

Host github.com
    IdentityFile ~/.ssh/github_deploy
    IdentitiesOnly yes

Copy ~/.ssh/github_deploy.pub and add it on GitHub under your repo's Settings → Deploy keys, leaving "Allow write access" unchecked. Then test it.

ssh -T git@github.com

You should see "Hi user/repo! You've successfully authenticated".

Clone and build

sudo mkdir -p /var/www/techalyst.com
sudo chown deploy:deploy /var/www/techalyst.com
cd /var/www/techalyst.com
git clone git@github.com:youruser/yourrepo.git .

composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build

The production .env

Copy the example and fill it in. Never commit this file.

cp .env.example .env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://techalyst.com

LOG_CHANNEL=daily
LOG_LEVEL=info

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=techalyst
DB_USERNAME=techalyst
DB_PASSWORD=the-password-from-step-three

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1

SESSION_SECURE_COOKIE=true
SESSION_DOMAIN=techalyst.com
SESSION_LIFETIME=10080

MAIL_MAILER=log

A few notes. LOG_CHANNEL=daily keeps laravel.log from growing without bound. If you use Sanctum in SPA mode, add SANCTUM_STATEFUL_DOMAINS=techalyst.com,www.techalyst.com. If Telescope or Pulse are in the project, switch them off on prod with TELESCOPE_ENABLED=false and PULSE_ENABLED=false, because they record heavy per request data. Wire a real mail provider like Resend or Postmark when you are ready to send real email.

Now generate the key, migrate, link storage and cache everything.

php artisan key:generate --force
php artisan migrate --force
php artisan storage:link
php artisan optimize

Get the permissions right

Laravel writes to storage and bootstrap/cache. Set directory and file modes with find rather than a blunt chmod -R, which would also change tracked file modes and leave git showing phantom changes.

sudo find /var/www/techalyst.com -type d -exec chmod 775 {} \;
sudo find /var/www/techalyst.com -type f -exec chmod 664 {} \;
sudo chown -R deploy:deploy /var/www/techalyst.com

Do not strip the world read and traverse bits off storage/app/public. Caddy serves your uploaded images from there through the public/storage link and needs to read them.

Tell Caddy about the site

sudo nano /etc/caddy/Caddyfile
techalyst.com {
    root * /var/www/techalyst.com/public
    encode zstd gzip
    php_fastcgi unix//run/php/php8.4-fpm.sock
    file_server

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}

www.techalyst.com {
    redir https://techalyst.com{uri} permanent
}

Caddy does not add those security headers on its own, so we set them. It also does not add HSTS by default, which is why it is in the list. Note we do not add custom file logging here, because AppArmor on the packaged Caddy unit blocks writes to /var/log/caddy/. Read its logs with journalctl -u caddy instead.

Validate before reloading, so a typo cannot take the site down.

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Visit https://techalyst.com. Caddy requests a Let's Encrypt certificate over the HTTP-01 challenge on the first hit, which takes ten to thirty seconds, then you are on HTTPS with a valid padlock.

Run Horizon and the scheduler with systemd

A real app needs the queue worker running always and the scheduler firing every minute. systemd handles both and restarts them if they fall over.

Horizon, the queue supervisor.

sudo nano /etc/systemd/system/horizon.service
[Unit]
Description=Laravel Horizon
After=network.target redis-server.service mysql.service
Requires=redis-server.service

[Service]
User=deploy
Group=deploy
Restart=always
RestartSec=5
WorkingDirectory=/var/www/techalyst.com
ExecStart=/usr/bin/php artisan horizon

[Install]
WantedBy=multi-user.target

The scheduler, as a oneshot service plus a timer. This is cleaner than a crontab line because the logs flow to journald and the dependency on Redis and MySQL is explicit.

sudo nano /etc/systemd/system/laravel-scheduler.service
[Unit]
Description=Laravel scheduler
After=mysql.service redis-server.service

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/var/www/techalyst.com
ExecStart=/usr/bin/php artisan schedule:run
sudo nano /etc/systemd/system/laravel-scheduler.timer
[Unit]
Description=Run the Laravel scheduler every minute

[Timer]
OnCalendar=*-*-* *:*:00

[Install]
WantedBy=timers.target

Enable and start them, then verify.

sudo systemctl daemon-reload
sudo systemctl enable --now horizon laravel-scheduler.timer
systemctl is-active horizon laravel-scheduler.timer
php artisan horizon:status
systemctl list-timers

Automate deploys with GitHub Actions

Manual deploys get tedious and easy to get wrong. Let a push to main do the work.

First, a separate CI key on the droplet, never your personal one.

ssh-keygen -t ed25519 -f ~/.ssh/ci_deploy -N ""
cat ~/.ssh/ci_deploy.pub >> ~/.ssh/authorized_keys

Put the private key (~/.ssh/ci_deploy) into a GitHub repo secret named DROPLET_SSH_KEY, and add DROPLET_HOST and DROPLET_USER secrets too.

Allow deploy to reload PHP FPM without a password, scoped to that one command only.

echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.4-fpm' | sudo tee /etc/sudoers.d/deploy-reload

Then add .github/workflows/deploy.yml.

name: Deploy
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DROPLET_HOST }}
          username: ${{ secrets.DROPLET_USER }}
          key: ${{ secrets.DROPLET_SSH_KEY }}
          script: |
            cd /var/www/techalyst.com
            git reset --hard origin/main
            composer install --no-dev --optimize-autoloader --no-interaction
            npm ci && npm run build
            php artisan migrate --force
            php artisan optimize:clear && php artisan optimize
            php artisan horizon:terminate
            sudo systemctl reload php8.4-fpm
            php artisan up
            curl -fsS https://techalyst.com > /dev/null

horizon:terminate makes Horizon restart and pick up the new code. The final curl is a health check that fails the job if the site does not respond. For extra safety, gate this deploy job behind a test job that runs your Pest suite against MySQL and Redis service containers.

You are live

Push to main, and within two or three minutes your Laravel app is on prod, over HTTPS, with the queue and scheduler under systemd and the server locked down. That is a production Laravel SaaS on a single droplet that you understand end to end.