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.
All comments ()
No comments yet
Be the first to leave a comment on this post.