GitHub Actions Tutorial: Automated Laravel Deploys over SSH
A dedicated deploy user, secure secrets, a tested workflow, Docker Compose, atomic symlink releases, health checks and one-click rollback.
Deploying by hand looks harmless until the day you skip a step. You SSH in, run git pull, then composer install, npm run build, php artisan migrate, clear and rebuild the caches, reload PHP-FPM. Forget one of those and production quietly serves stale CSS, or worse, new code against an old database.
A GitHub Actions pipeline fixes that. It's a YAML file inside .github/workflows/ that runs on every push, connects to your server over SSH and runs the exact same steps every time. This guide covers a classic Laravel setup (Nginx + PHP-FPM, no Docker), a Docker Compose setup, and atomic deploys with symlinks so visitors never hit a half-updated site.
Why Manual Deploys Stop Making Sense in 2026
Once deploys are automated, every push to main goes through the same sequence: pull, install, build, migrate, cache, health check, notify. Nothing gets skipped because someone was tired, and that's what CI/CD is really about, not only running tests.
A few side benefits you get for free:
- Fewer people need SSH access. Developers deploy with
git push. Only one dedicated deploy key touches the server. - A clear history. Every deploy shows up in the Actions tab: who triggered it, which commit, how long it took and where it failed.
- One-click rollback. With a rollback workflow (Step 8), going back to the previous release is a button in the GitHub UI.
This article is for someone who wants CI/CD running on a Laravel or Node.js project in one afternoon. Not theory, not a ten-part series.
Prerequisites: What You Need Before Starting
This tutorial assumes you have:
- A Linux VPS (Ubuntu 22.04 or 24.04 in the examples) from any provider.
- A site already running on Nginx + PHP-FPM or Docker.
- Basic comfort with SSH and Git (branch, push, pull).
- A GitHub repository, public or private.
If SSH keys are new to you, skim the OpenSSH manual pages first. If you've never configured Nginx for Laravel, start with Laravel's deployment guide.
Architecture Overview: How GitHub Actions Connects to Your Server
Before any YAML, here's what happens under the hood. GitHub Actions runs your job on a runner, a virtual machine that GitHub hosts or one you host yourself. To reach your server you have two options:
GitHub repo
│
│ push to main
▼
GitHub Actions runner (ubuntu-latest)
│
│ SSH connection
│ using the SSH_PRIVATE_KEY secret
▼
Your VPS (Nginx + PHP-FPM + Laravel)
Option 1: GitHub-hosted runner + an SSH action (simpler). GitHub's runner runs the job and connects to your server over SSH. The private key lives only in GitHub Secrets, and the runner is a fresh VM for each job.
Option 2: Self-hosted runner (no SSH hop). You install the runner software on your own machine, so the job runs locally. The catch is security: GitHub's own security guidance says self-hosted runners should almost never be used for public repositories, because anyone can open a pull request and run code on that machine. Running one directly on your production server makes that risk worse.
For most small and medium projects, Option 1 is the simpler and safer starting point.
Step 1: Prepare the Server (Deploy User and a Dedicated SSH Key)
First rule: don't deploy as root. Create a dedicated user with limited access:
# On your server, as a sudo user
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/myapp
sudo chown deploy:www-data /var/www/myapp
Now create a key pair just for GitHub Actions. This key should only be able to log in as deploy:
# On your server, as the deploy user (sudo -iu deploy)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_key -N ""
cat ~/.ssh/github_actions_key.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Copy the contents of ~/.ssh/github_actions_key (the private key) into GitHub Secrets in the next step, then delete that private key file from the server. The public half, github_actions_key.pub, is the one now listed in authorized_keys.
Why ed25519? Ed25519 keys are short, fast and supported by every current OpenSSH release, which makes them a sensible default for a new deploy key.
If your workflow needs to reload PHP-FPM, give deploy permission for that one command only, instead of full sudo. Create /etc/sudoers.d/deploy with sudo visudo -f /etc/sudoers.d/deploy and add:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm
Change php8.3-fpm to match the PHP version on your server.
Step 2: Set Up GitHub Secrets Securely
Go to Settings → Secrets and variables → Actions → New repository secret and add:
| Secret name | Value | Description |
|---|---|---|
SSH_HOST |
server.example.com or an IP |
Server address |
SSH_USER |
deploy |
SSH user |
SSH_PORT |
22 or your custom port |
SSH port |
SSH_PRIVATE_KEY |
Contents of github_actions_key |
Private key |
SSH_FINGERPRINT |
Your server's SHA256 host key fingerprint | Protects against man-in-the-middle attacks |
DEPLOY_PATH |
/var/www/myapp |
Project path on the server |
SLACK_WEBHOOK_URL |
https://hooks.slack.com/... |
Optional, for notifications |
To get the host fingerprint, the ssh-action README suggests running this against your server (swap ed25519 for your host key type if needed):
ssh server.example.com ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | cut -d ' ' -f2
Security note: never commit a private key to the repository. Anyone who gets it can log in to your server as deploy. GitHub Secrets are encrypted and only exposed to workflow runs.
Step 3: Write the Workflow (The Simplest Case)
Create .github/workflows/deploy.yml in your repository:
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # allow manual deploys from the GitHub UI
concurrency:
group: production-deploy
cancel-in-progress: false # never kill a deploy halfway through
jobs:
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
command_timeout: 8m
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
echo "==> Pulling latest from origin/main"
git fetch --prune origin
git reset --hard origin/main
git log -1 --pretty=format:'HEAD now at %h %s (%an, %ar)'
This runs on every push to main. git reset --hard origin/main makes the server match the pushed commit exactly, so if a previous run was interrupted, the next one puts HEAD back where it belongs. It also throws away any manual edits on the server, which is exactly what you want in production.
Why cancel-in-progress: false? If a new push lands mid-deploy, the running deploy finishes first and the new one waits. Cancelling halfway could leave the server with some steps done and others not.
Why set -e? Older ssh-action versions had a script_stop option. It has been removed, and the README now says to put set -e at the top of your script instead, so the script stops at the first failing command.
Step 4: A Real Laravel Scenario (Without Docker)
Many Laravel projects run directly on Nginx + PHP-FPM, not in containers. This workflow covers that setup:
name: Deploy Laravel
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
command_timeout: 12m
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
echo "==> Pulling latest code"
git fetch --prune origin
git reset --hard origin/main
echo "==> Installing PHP dependencies"
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
echo "==> Building frontend assets"
npm ci --no-audit
npm run build
echo "==> Running migrations"
php artisan migrate --force --no-interaction
echo "==> Caching config, routes, views and events"
php artisan optimize
echo "==> Reloading PHP-FPM"
sudo systemctl reload php8.3-fpm
echo "==> Telling queue workers to restart"
php artisan queue:restart
echo "==> Health check"
sleep 2
curl -fsS -o /dev/null -w "HTTP %{http_code} in %{time_total}s\n" https://example.com/up
What matters here:
set -estops the deploy at the first failing command, so a brokencomposer installdoesn't continue into migrations.composer install --no-devskips dev-only packages such as PHPUnit or Debugbar.npm ciinstalls exactly what's inpackage-lock.jsonand fails if the lock file andpackage.jsondisagree. That's what you want in CI. Building on the server needs Node installed there; if you'd rather build on the runner, our Laravel Vite manifest not found guide shows how.migrate --forceis required because Laravel asks for confirmation before running migrations in production.php artisan optimizecaches config, routes, views and events in one command. Runconfig:cache,route:cacheandview:cacheseparately if you want to see exactly which one failed.queue:restartmakes long-running workers pick up the new code after their current job./upis the health route that new Laravel apps register by default. It returns 200 when the app boots without errors (see the health route section of the docs). Replaceexample.comwith your domain.environment: productionties the job to a GitHub environment, where you can require a reviewer to approve each deploy.
This setup does have a short window where files change under a live site. Step 6 removes that window.
Step 5: Docker Compose Scenario
If your app runs in containers, the workflow looks different:
name: Deploy with Docker
on:
push:
branches: [main]
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
command_timeout: 12m
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
echo "==> Pulling latest code"
git fetch --prune origin
git reset --hard origin/main
echo "==> Pulling updated images"
docker compose pull --ignore-buildable
echo "==> Rebuilding and restarting, waiting for healthy containers"
docker compose up -d --build --remove-orphans --wait --wait-timeout 120
echo "==> Pruning dangling images"
docker image prune -f
--wait makes docker compose up wait until services are running or healthy, and fail if they don't get there within --wait-timeout seconds (see the compose up reference). For that to mean anything, define a healthcheck for your services in compose.yaml. --ignore-buildable skips images that Compose builds locally, so pull doesn't fail on them.
Be clear about what this protects you from. If the new container never becomes healthy, the workflow fails and you get notified, but the old container has already been replaced. For true zero-downtime container deploys you need a blue-green setup or a way to quickly redeploy the previous image tag.
Step 6: Atomic Deploy with Symlinks (Real Zero Downtime)
The problem with updating files in place is the window while they change. For a few seconds, some files are new and some are old, and a visitor who lands right then can hit an error. The fix is an atomic deploy:
# Folder layout on the server
/var/www/myapp/
├── releases/
│ ├── 2026-08-05-143022/
│ ├── 2026-08-05-150011/
│ └── 2026-08-06-094530/ ← the new release
├── shared/
│ ├── .env ← shared across releases
│ └── storage/ ← uploads, logs, sessions
└── current → releases/2026-08-06-094530/ ← symlink
Point Nginx's root at /var/www/myapp/current/public. Swapping the current symlink is a single rename, so requests see either the old release or the new one, never a mix.
#!/bin/bash
set -euo pipefail
APP=/var/www/myapp
RELEASE="$APP/releases/$(date +%Y-%m-%d-%H%M%S)"
SHARED="$APP/shared"
# 1. Create the new release
git clone --depth 1 --branch main git@github.com:your-org/your-repo.git "$RELEASE"
# 2. Link shared files
ln -s "$SHARED/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -s "$SHARED/storage" "$RELEASE/storage"
# 3. Build
cd "$RELEASE"
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
npm ci --no-audit && npm run build
php artisan storage:link
# 4. Run migrations (from the new release, before switching)
php artisan migrate --force --no-interaction
# 5. Optimize
php artisan optimize
# 6. Swap the symlink atomically
ln -sfn "$RELEASE" "$APP/current_new"
mv -Tf "$APP/current_new" "$APP/current"
# 7. Make PHP-FPM pick up the new path and tell workers to restart
sudo systemctl reload php8.3-fpm
php artisan queue:restart
# 8. Keep the 5 newest releases
cd "$APP/releases"
ls -1t | tail -n +6 | xargs -r rm -rf
echo "Deployed $RELEASE"
Save it on the server as /var/www/myapp/deploy.sh (owned by deploy, chmod 750) and call it from the workflow:
- uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
script: bash /var/www/myapp/deploy.sh
A few details that matter:
mv -Tfinstead ofln -sfnoncurrent.ln -sfnremoves the old link and creates a new one, which leaves a tiny gap.mv -Tuses a singlerename(), which is atomic as long as both paths are on the same filesystem.- Reload PHP-FPM after the swap. PHP caches resolved paths and compiled files, so without a reload some workers can keep serving the old release for a while.
- The server needs read access to the repo. For a private repository, add a read-only deploy key for the server's
deployuser. - Migrations run while the old release is still live. Keep them backward compatible (add columns first, remove old ones in a later deploy) so the old code doesn't break during that window.
- Make shared storage writable once, at setup time:
sudo chown -R deploy:www-data /var/www/myapp/shared/storageandsudo chmod -R ug+rwX /var/www/myapp/shared/storage. PHP-FPM only needs to read the rest of the release.
Step 7: Health Check and Notification
After the deploy, check that the site really answers, then tell the team:
- name: Health check
run: |
curl -fsS --retry 5 --retry-delay 3 --retry-all-errors -o /dev/null https://example.com/up
echo "Health check passed"
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v4.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "Deploy ${{ job.status }}: ${{ github.sha }} by ${{ github.actor }}"
curl -f fails on any HTTP error status, and the retries give the app a few seconds to warm up. if: always() makes the notification run even when an earlier step failed, which is exactly when you want to hear about it. The Slack step follows the incoming webhook setup in Slack's docs.
If your team lives in Telegram instead, appleboy/telegram-action does the same job:
- name: Notify Telegram
if: always()
uses: appleboy/telegram-action@v1
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
message: "Deploy ${{ job.status }}: ${{ github.repository }}@${{ github.sha }}"
Step 8: Fast Rollback When a Deploy Breaks
It happens: a deploy goes out, you spot a bug in production, and you need the previous version back now. With the release folders from Step 6, rollback is just pointing the symlink back:
name: Rollback Production
on:
workflow_dispatch:
inputs:
release:
description: "Release folder to roll back to (e.g. 2026-08-05-150011)"
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: appleboy/ssh-action@v1.2.5
env:
RELEASE_NAME: ${{ inputs.release }}
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
envs: RELEASE_NAME
script: |
set -e
case "$RELEASE_NAME" in
''|*[!0-9-]*) echo "Invalid release name"; exit 1 ;;
esac
APP=/var/www/myapp
TARGET="$APP/releases/$RELEASE_NAME"
if [ ! -d "$TARGET" ]; then
echo "Release not found: $TARGET"
exit 1
fi
ln -sfn "$TARGET" "$APP/current_new"
mv -Tf "$APP/current_new" "$APP/current"
sudo systemctl reload php8.3-fpm
cd "$APP/current" && php artisan queue:restart
echo "Rolled back to $TARGET"
Notice that the release name is passed through an environment variable (envs) and validated, instead of pasting ${{ inputs.release }} straight into the script. GitHub's security guidance recommends that pattern to avoid script injection. Each old release still has its own cached config, so you don't need to rebuild caches; the PHP-FPM reload is what matters.
In GitHub, open Actions → Rollback Production → Run workflow, type the release folder name and run it. Remember that rolling back code doesn't roll back migrations; if the bad deploy changed the schema, plan that separately.
Hosted Runner vs Self-Hosted? (Real Comparison)
| GitHub-hosted runner | Self-hosted runner | |
|---|---|---|
| Cost | Free for public repos on standard runners. Private repos: 2,000 minutes/month on GitHub Free, then billed per minute | No GitHub minute charges; you pay for the machine and its upkeep |
| Maintenance | GitHub maintains the image | You update and secure it |
| Security | Fresh VM for every job | Not guaranteed clean between jobs; GitHub advises against it for public repos |
| Access to internal services | Needs an exposed endpoint or a tunnel | Direct network access |
| Best for | Small teams, most projects | Heavy builds, private networks, special hardware |
Numbers come from GitHub's Actions billing page. At the time of writing, a standard Linux 2-core runner is listed at $0.006 per minute beyond the free quota, and GitHub Pro includes 3,000 minutes.
Quick math: 10 deploys a day at 5 minutes each is 10 × 5 × 30 = 1,500 minutes a month, which fits inside the 2,000 free minutes for a private repo on GitHub Free. Tests and other workflows use the same pool, so count them too.
When does self-hosted make sense? When builds are heavy, when you need to reach a database or service that isn't exposed to the internet, or when you need hardware GitHub doesn't offer. For a typical Laravel deploy, the hosted runner is enough.
appleboy/ssh-action vs rsync-deployments vs scp-action
| Action | Strength | Drawback | Best use |
|---|---|---|---|
| appleboy/ssh-action | Simple, widely used, runs any command | The script is a string, so quoting can get awkward | Most deploys |
| burnett01/rsync-deployments | Syncs only changed files, can delete removed ones | Doesn't run commands | Uploading build output |
| appleboy/scp-action | Copies files and folders over SCP | No incremental sync | Uploading an archive or a few files |
A solid combo: build on the runner, rsync the result to the server, then use ssh-action to finish:
steps:
- uses: actions/checkout@v7
# ... set up PHP/Node and build here, e.g. composer install and npm run build ...
- name: Sync files with rsync
uses: burnett01/rsync-deployments@v9
with:
switches: -avz --delete --exclude=.git --exclude=node_modules --exclude=.env --exclude=storage
path: ./
remote_path: ${{ secrets.DEPLOY_PATH }}
remote_host: ${{ secrets.SSH_HOST }}
remote_port: ${{ secrets.SSH_PORT }}
remote_user: ${{ secrets.SSH_USER }}
remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Run post-deploy commands
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
fingerprint: ${{ secrets.SSH_FINGERPRINT }}
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
php artisan migrate --force --no-interaction
php artisan optimize
sudo systemctl reload php8.3-fpm
Be careful with --delete: it removes anything on the server that isn't in the source, which is why .env and storage are excluded above. Inputs follow the rsync-deployments README.
GitHub Actions vs GitLab CI / Jenkins / Deployer
| Tool | Main strength | Best for |
|---|---|---|
| GitHub Actions | Built into GitHub, no server to run | Projects hosted on GitHub |
| GitLab CI | Built into GitLab, strong self-hosting story | Projects hosted on GitLab |
| Jenkins | Very flexible, huge plugin ecosystem | Large teams with unusual pipelines and ops capacity |
| Deployer | PHP deploy tool with release folders and atomic switches built in | PHP teams that want deploy logic in PHP |
| Laravel Envoyer | Laravel's hosted zero-downtime deploy service | Teams that prefer a paid managed service |
Our take: if the code is already on GitHub, GitHub Actions is the obvious first choice. If you'd rather keep the deploy logic in PHP, look at Deployer's Laravel recipe; it implements the same release-folder pattern as Step 6, and you can still trigger it from GitHub Actions.
Common Mistakes + Debugging
These are the problems that show up again and again:
1. SSH Key With a Passphrase
A passphrase-protected key works fine, but only if you also give it to the action. ssh-action has a passphrase input, so store the passphrase as another secret (passphrase: ${{ secrets.SSH_PASSPHRASE }}). If you leave it out, authentication fails. Many teams use a key without a passphrase for the deploy user and rely on GitHub Secrets plus a restricted deploy account instead.
2. Forgetting set -e in the Script
Without set -e, a failing command doesn't stop the script. composer install can fail and the script will happily continue to migrations and cache commands, and the action may still report success because only the last command's exit code counts. Put set -e on the first line of every deploy script.
3. A Migration That Destroys Data
If a migration drops or truncates anything, take a backup first. Keep database credentials in the deploy user's ~/.my.cnf (mode 600) rather than on the command line, where they can leak into the process list and shell history:
# Before migrating
mysqldump --single-transaction your_database | gzip > ~/backups/db-$(date +%F-%H%M%S).sql.gz
php artisan migrate --force --no-interaction
4. Stale Code or Cache After Deploy
If the site still shows old behavior, it's usually OPcache or a cached config. Reload PHP-FPM after every deploy (the sudoers rule from Step 1 makes that safe), and if caches look wrong, run php artisan optimize:clear followed by php artisan optimize.
5. Wrong Concurrency Setting
cancel-in-progress: true can stop a deploy in the middle and leave the server half-updated. For production keep it false. For a staging environment where only the latest commit matters, true is fine.
6. Permission denied on Files
Files written by the deploy are owned by deploy, while PHP-FPM usually runs as www-data. Only two places need to be writable by the web server: storage and bootstrap/cache. Fix those once instead of chmod-ing the whole project:
sudo chown -R deploy:www-data /var/www/myapp/storage /var/www/myapp/bootstrap/cache
sudo chmod -R ug+rwX /var/www/myapp/storage /var/www/myapp/bootstrap/cache
7. Workflow That Hangs Until Timeout
A job's default timeout-minutes is 360 (see the workflow syntax reference). That's six hours of burned minutes if something hangs. Set it to 10 or 20 minutes for deploys, and set ssh-action's command_timeout a bit lower so the remote script fails first with a clear message.
Frequently Asked Questions
Is GitHub Actions free for private repositories?
Partly. GitHub Free includes 2,000 minutes a month for private repositories on standard GitHub-hosted runners, and GitHub Pro includes 3,000. Public repositories on standard runners are free. Beyond the quota, usage is billed per minute, per GitHub's billing docs.
How do I deploy to staging and production from the same repo?
Create two GitHub environments, staging and production, each with its own secrets. Use one job per environment, and add required reviewers to production so it only deploys after someone approves.
Can I deploy without SSH?
Yes. A self-hosted runner on the server can run the steps locally, or the server can pull a new Docker image from a registry. Both have trade-offs: self-hosted runners need careful security, and image-based deploys need a registry and a pull mechanism. For most small projects SSH is the simplest option.
What happens if the server goes down mid-deploy?
The workflow fails and the notification step tells you. With the atomic setup from Step 6, current still points to the last complete release unless the swap already happened, so the site keeps serving a working version. Once the server is back, re-run the workflow.
Can I test on staging before production?
Yes. A common flow: run tests and lint on every pull request, deploy to staging automatically when you merge to a develop branch, and deploy to production from main with a required approval on the production environment.
Do I need to commit node_modules or vendor?
No. Both belong in .gitignore. The workflow recreates them with npm ci and composer install on every deploy.
Summary + Final Checklist
To get this running today:
- Create a
deployuser on the server (not root). - Generate a dedicated ed25519 key for GitHub Actions.
- Add the private key, host, port, user, path and host fingerprint to GitHub Secrets.
- Create
.github/workflows/deploy.ymlfrom the Step 4 template. - Run the first deploy manually with
workflow_dispatchso you can watch it. - Once it works, protect
mainso changes land through pull requests. - Add the health check and a Slack or Telegram notification.
- Set up the rollback workflow and test it once on staging.
One piece of advice: don't run your first automated production deploy late on a Friday. Do it when you have time to watch it and fix things.
If your project is bilingual and you care about search, our Laravel technical SEO guide covers canonical and hreflang setup, which deploys won't fix for you.
Want This Set Up for Your Project?
If you'd rather have someone else wire up deploys, server hardening and monitoring, take a look at our security and maintenance service or get in touch. And if a workflow keeps failing with an error you can't decode, paste it into Nyx, our AI coding assistant, for a second opinion.
Comments
Join the discussionNo comments yet. Be the first to comment.