# GitHub Actions Tutorial: Automated Laravel Deploys over SSH

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](https://www.openssh.com/manual.html) first. If you've never configured Nginx for Laravel, start with [Laravel's deployment guide](https://laravel.com/docs/12.x/deployment).

## 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:

```plaintext
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](https://docs.github.com/en/actions/reference/security/secure-use) 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:

```bash
# 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`:

```bash
# 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:

```plaintext
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](https://github.com/appleboy/ssh-action) suggests running this against your server (swap `ed25519` for your host key type if needed):

```bash
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:

```yaml
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:

```yaml
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 -e` stops the deploy at the first failing command, so a broken `composer install` doesn't continue into migrations.
- `composer install --no-dev` skips dev-only packages such as PHPUnit or Debugbar.
- `npm ci` installs exactly what's in `package-lock.json` and fails if the lock file and `package.json` disagree. 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](https://captaincode.tech/en/blog/laravel-vite-manifest-not-found-production) shows how.
- `migrate --force` is required because Laravel asks for confirmation before running migrations in production.
- `php artisan optimize` caches config, routes, views and events in one command. Run `config:cache`, `route:cache` and `view:cache` separately if you want to see exactly which one failed.
- `queue:restart` makes long-running workers pick up the new code after their current job.
- `/up` is the health route that new Laravel apps register by default. It returns 200 when the app boots without errors (see the [health route section](https://laravel.com/docs/12.x/deployment#the-health-route) of the docs). Replace `example.com` with your domain.
- `environment: production` ties the job to a GitHub [environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), 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:

```yaml
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](https://docs.docker.com/reference/cli/docker/compose/up/)). 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:

```plaintext
# 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.

```bash
#!/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:

```yaml
- 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 -Tf` **instead of** `ln -sfn` **on** `current`**.** `ln -sfn` removes the old link and creates a new one, which leaves a tiny gap. `mv -T` uses a single `rename()`, 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](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys) for the server's `deploy` user.
- **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/storage` and `sudo 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:

```yaml
      - 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](https://docs.slack.dev/tools/slack-github-action/sending-data-slack-incoming-webhook/).

If your team lives in Telegram instead, [appleboy/telegram-action](https://github.com/appleboy/telegram-action) does the same job:

```yaml
      - 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:

```yaml
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](https://docs.github.com/en/actions/reference/security/secure-use) 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](https://docs.github.com/en/billing/concepts/product-billing/github-actions). 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:

```yaml
    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](https://github.com/Burnett01/rsync-deployments).

## 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](https://deployer.org/docs/8.x/recipe/laravel); 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:

```bash
# 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:

```bash
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](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). 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:

1. Create a `deploy` user on the server (not root).
2. Generate a dedicated ed25519 key for GitHub Actions.
3. Add the private key, host, port, user, path and host fingerprint to GitHub Secrets.
4. Create `.github/workflows/deploy.yml` from the Step 4 template.
5. Run the first deploy manually with `workflow_dispatch` so you can watch it.
6. Once it works, protect `main` so changes land through pull requests.
7. Add the health check and a Slack or Telegram notification.
8. 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](https://captaincode.tech/en/blog/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](https://captaincode.tech/en/services/security-maintenance) or [get in touch](https://captaincode.tech/en/contact). And if a workflow keeps failing with an error you can't decode, paste it into [Nyx](https://captaincode.tech/en/labs/nyx), our AI coding assistant, for a second opinion.
