Skip to content
On this page

Deploy a Laravel + Tailwind Site to CloudPanel via GitHub Actions

This guide shows how to set up a fast, repeatable deployment for a Laravel/Tailwind application onto a server managed with CloudPanel. On every push to main, GitHub Actions installs Composer and npm dependencies, builds the production assets, and copies the result to the server over SSH.

The idea is to build everything on the runner and ship the finished artifacts. The server never needs Composer or Node installed - it only serves the files.

Overview

push to main
   │
   ▼
GitHub Actions runner
   ├─ composer install --no-dev --optimize-autoloader
   ├─ npm ci && npm run build      (Vite) / npm run prod (Mix)
   └─ rsync over SSH ──────────────► CloudPanel server
                                        └─ php artisan migrate / cache

Prerequisites

  • A Laravel project on GitHub
  • A server with CloudPanel installed
  • A domain (or subdomain) pointing to the server's IP

1. Create the site in CloudPanel

  1. In CloudPanel, go to Sites → Add Site → Create a PHP Site.
  2. Pick Laravel as the application (or Generic PHP and set the PHP version, e.g. 8.3).
  3. Enter your domain and let CloudPanel create a Site User. Note the user name - you'll need it for SSH.

CloudPanel places the site files under:

/home/<site-user>/htdocs/<domain>

For Laravel, the web root must point to the public folder, set under Site Settings → Root:

/home/<site-user>/htdocs/<domain>/public

Set the root after the first deploy

Don't point the root at public while the folder doesn't exist yet. If you do, CloudPanel creates the missing public directory itself - owned by root:root. Your site user can't write to or delete it, so the first rsync fails and you're stuck with an undeletable folder.

Leave the root at the default (/htdocs/<domain>) until the first deploy has run and rsync has created public (owned by the site user). Then switch the root to .../public.

2. Allow SSH access for the site user

The deployment authenticates as the CloudPanel site user over SSH using a key pair.

Generate a dedicated key pair on your machine:

bash
ssh-keygen -t ed25519 -C "github-deploy" -f ~/.ssh/github_deploy

Use ed25519, not RSA

Some SFTP actions choke on the newer OpenSSH key format and only accept the old PEM layout. Deploying with plain rsync/ssh (as below) avoids that problem entirely, so a modern ed25519 key works without conversion.

Add the public key (~/.ssh/github_deploy.pub) to the server. Print it first:

bash
cat ~/.ssh/github_deploy.pub

Then in CloudPanel:

  1. Go to Sites → your site → Settings → Site User Settings.
  2. Paste the output into the SSH Keys field and save.

Verify the connection from your machine:

bash
ssh -i ~/.ssh/github_deploy <site-user>@<server-ip>

One key or one per site?

You only need to generate one key pair. What decides whether a key can reach one site or all of them isn't the key itself - it's where you install the public key.

Each CloudPanel site has its own site user (a separate Linux user with its own home directory and its own htdocs). SSH authorizes per user: a key works for a site only if its public key sits in that site user's authorized keys. A site user can't touch another site's files, so the scope follows naturally:

  • One site only - paste the public key into a single site user. That key can SSH in only as that user and only touch that one site. This is the default and the safest.
  • Multiple sites - paste the same public key into each site user you want to deploy to. One private key in your GitHub secrets, authorized in several places.
github_deploy.pub
   ├─ added to site-user-A  → key can deploy site A
   └─ added to site-user-B  → key can deploy site B
   (NOT added to site-user-C → key cannot touch site C)

So "scoping" a key is just a question of which site users carry the public key. To revoke access to one site, remove the public key from that site user - the others keep working.

Only the public key travels - never the private one

You don't copy anything "from site user A to site user B". The source is always your local github_deploy.pub. To authorize another site, print it again and paste it into that site's user:

bash
cat ~/.ssh/github_deploy.pub

The private key (~/.ssh/github_deploy, no .pub) stays only on your machine and in the GitHub secrets - it must never be uploaded to the server. The server only stores public keys and checks that a login presents the matching private key.

Shared key vs. one key per site

Reusing one key everywhere is simpler but means a leak affects every site at once. If you host unrelated or client projects, generate a separate key pair per project (one private key per repo's secrets, each public key in only its own site user) so you can revoke them independently.

3. Add GitHub secrets

In your repository: Settings → Secrets and variables → Actions → New repository secret.

SecretValue
SERVER_IPServer IP or hostname
SSH_KEYContents of ~/.ssh/github_deploy (the private key)

Copy the private key with:

bash
cat ~/.ssh/github_deploy

Paste everything including the -----BEGIN ...----- and -----END ...----- lines.

Two secrets are enough

Only the IP and the private key are sensitive. The site user and the target path aren't secret, so we hardcode them in the workflow below. The SSH port defaults to 22, so there's no need for a secret either. If you ever run on a non-standard port, add -p <port> to the ssh/rsync commands.

4. The GitHub Actions workflow

Create .github/workflows/deploy.yml:

yaml
name: Deploy

on:
  push:
    branches: [main]

env:
  SSH_USER: my-site-user                              # CloudPanel site user
  DEPLOY_PATH: /home/my-site-user/htdocs/example.com  # site root

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # --- PHP / Composer ---
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - name: Install Composer dependencies
        run: composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction

      # --- Node / assets ---
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Build assets
        run: |
          npm ci
          npm run build      # Laravel Mix: npm run prod

      # --- SSH setup ---
      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
          chmod 600 ~/.ssh/id_deploy
          ssh-keyscan ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

      # --- Copy files ---
      - name: Deploy with rsync
        run: |
          rsync -azr --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='.env' \
            --exclude='node_modules' \
            --exclude='storage' \
            -e "ssh -i ~/.ssh/id_deploy" \
            ./ $SSH_USER@${{ secrets.SERVER_IP }}:$DEPLOY_PATH/

      # --- Post-deploy ---
      - name: Run artisan commands
        run: |
          ssh -i ~/.ssh/id_deploy $SSH_USER@${{ secrets.SERVER_IP }} \
            "cd $DEPLOY_PATH && \
             php artisan migrate --force && \
             php artisan config:cache && \
             php artisan route:cache && \
             php artisan view:cache"

Set SSH_USER and DEPLOY_PATH at the top of the workflow to match your CloudPanel site user and domain.

What the workflow does

  • composer install --no-dev --optimize-autoloader builds a production-ready vendor/ directory. Because it's shipped to the server, no Composer is needed there.
  • npm ci && npm run build compiles Tailwind and your JS into public/build (Vite) or public (Mix). Use npm run prod if your project still uses Laravel Mix.
  • rsync --delete mirrors the build to the server, removing stale files.
  • The final SSH step runs migrations and rebuilds Laravel's caches.

5. Why those rsync excludes matter

ExcludeReason
.envLives only on the server - never overwrite it from the repo
storageHolds logs, sessions, and uploads that must persist between deploys
node_modulesDev-only; the compiled assets are already in public
.git / .githubNo need to ship version-control files

First deploy

Because storage is excluded, it won't exist on a brand-new site. After the first deploy, create it once over SSH so Laravel can write logs and caches:

bash
ssh <site-user>@<server-ip>
cd htdocs/example.com
mkdir -p storage/framework/{cache,sessions,views} storage/logs bootstrap/cache
php artisan storage:link

6. Set up the server .env once

Create .env on the server (not in the repo) and set production values:

bash
ssh <site-user>@<server-ip>
cd htdocs/example.com
cp .env.example .env
nano .env          # set APP_ENV=production, APP_KEY, DB credentials, etc.
php artisan key:generate

CloudPanel can create the MySQL database and user under Databases; use those credentials in .env.

Done

Push to main and watch the Actions tab. Once green, your Laravel + Tailwind site is live. Every subsequent push redeploys automatically.

Keep builds fast

The cache: 'npm' option and Composer's built-in caching make repeat runs noticeably quicker. For larger apps, add actions/cache for the Composer directory as well.