Automatically Deploy Laravel and Vue to cPanel with GitHub Actions
Uploading a ZIP file to cPanel after every small code change becomes frustrating very quickly.
You fix a bug, build the Vue frontend, create an archive, open cPanel File Manager, upload it, extract it, run migrations, clear Laravel caches, and finally check whether the application still works.
The code change may take two minutes. The deployment takes much longer.
A better approach is:
Push the code to GitHub and let GitHub Actions build and deploy the application to cPanel automatically.
After the initial setup, deployment becomes:
git add .
git commit -m "Fix checkout issue"
git push origin main
GitHub Actions handles the remaining work.
The deployment architecture
This guide uses a push-based deployment:
The cPanel server does not clone or pull the private repository. Therefore, it does not need a GitHub deploy key, personal access token, or account-level GitHub SSH key.
GitHub Actions checks out its own repository with the workflow's built-in GITHUB_TOKEN. The workflow only needs a separate SSH key for this direction:
GitHub Actions → cPanel
This keeps the setup simpler and avoids storing an unnecessary GitHub repository credential on the production server.
What the workflow will do
Every push to the main branch will:
- Check out the private repository.
- Install Laravel's production dependencies.
- Install frontend dependencies and build the Vue/Vite assets.
- Package the application without
.env,storage, or server-managed files. - Connect to cPanel through SSH.
- Upload and extract the new application files.
- Preserve the production
.env, uploaded files, logs, and SSL challenge files. - Run database migrations and Laravel optimization commands.
- Signal existing queue workers to restart.
- Check a public production URL when one is configured.
This is a practical deployment for small and medium applications. It is not a zero-downtime or automatic-rollback system.
Before you start
You need:
- A Laravel application using Vue and Vite.
- A private GitHub repository.
-
composer.lockandpackage-lock.jsoncommitted to the repository. - SSH access to the cPanel account.
- A domain whose document root can point to Laravel's
publicdirectory. - The correct command-line PHP executable for the cPanel account.
Some shared-hosting providers disable SSH, symbolic links, long-running queue workers, or custom document roots. Confirm those features with your hosting provider before continuing.
Step 1: Prepare the application directory
Open:
cPanel → Advanced → Terminal
Check the cPanel username and home directory:
whoami
echo "$HOME"
Create the Laravel application directory:
mkdir -p "$HOME/example.com"
cd "$HOME/example.com"
pwd
Example output:
/home/exampleuser/example.com
Save this complete path. It will become the GitHub variable:
APP_DIR=/home/exampleuser/example.com
Configure the domain document root as:
/home/exampleuser/example.com/public
Do not point the domain to the Laravel project root. Only the public directory should be web-accessible.
Step 2: Create the production .env
The workflow will never upload .env from GitHub.
Create the production file directly on cPanel:
cd "$HOME/example.com"
umask 077
touch .env
chmod 600 .env
nano .env
A minimal example looks like this:
APP_NAME="My Application"
APP_ENV=production
APP_KEY=base64:YOUR_APPLICATION_KEY
APP_DEBUG=false
APP_URL=https://example.com
LOG_CHANNEL=stack
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=exampleuser_database
DB_USERNAME=exampleuser_dbuser
DB_PASSWORD=YOUR_DATABASE_PASSWORD
Add the mail, cache, session, queue, API, and other values required by your application.
Generate an application key from a trusted local copy of the project:
php artisan key:generate --show
Copy the generated value into the production .env.
Never replace an existing production APP_KEY unless you understand the consequences. Changing it can invalidate encrypted cookies and make existing encrypted data unreadable.
Step 3: Find the correct PHP executable
In cPanel Terminal, run:
command -v php
php -v
On some servers, the correct binary may look like:
/opt/cpanel/ea-php84/root/usr/bin/php
Test the selected binary:
/opt/cpanel/ea-php84/root/usr/bin/php -v
Save the working path as:
PHP_BIN=/opt/cpanel/ea-php84/root/usr/bin/php
Use the PHP version required by your project. The PHP version used to build Composer dependencies in GitHub Actions should match the production server as closely as possible.
Step 4: Generate the deployment SSH key
Generate this key on your trusted local computer, not inside the repository and not on the cPanel server.
mkdir -p "$HOME/.ssh/deploy-keys"
chmod 700 "$HOME/.ssh/deploy-keys"
Create an Ed25519 key pair:
ssh-keygen \
-t ed25519 \
-C "github-actions-to-cpanel" \
-f "$HOME/.ssh/deploy-keys/cpanel_production" \
-N ""
This creates:
cpanel_production ← Private key
cpanel_production.pub ← Public key
The key locations will be:
Private key → GitHub Actions secret
Public key → cPanel authorized keys
The private key must never be committed to the repository.
Step 5: Authorize the public key in cPanel
Display the public key locally:
cat "$HOME/.ssh/deploy-keys/cpanel_production.pub"
Copy the complete line.
In cPanel, open:
Security
→ SSH Access
→ Manage SSH Keys
→ Import Key
Paste the public key and import it. Then select:
Manage → Authorize
Importing and authorizing may be separate actions in cPanel.
You can also add the public key from cPanel Terminal:
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
nano "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys"
Paste the public key on its own line.
Optional key restrictions
After the deployment works, you may restrict the key in authorized_keys:
no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3... github-actions-to-cpanel
These options disable common SSH features that the deployment does not need. Test the normal key first because hosting configurations vary.
Test the connection
From your local computer, run:
ssh \
-i "$HOME/.ssh/deploy-keys/cpanel_production" \
-p YOUR_SSH_PORT \
YOUR_CPANEL_USER@YOUR_CPANEL_HOST
Example:
ssh \
-i "$HOME/.ssh/deploy-keys/cpanel_production" \
-p 22 \
exampleuser@server123.hosting-company.com
Do not continue until this connection works.
Step 6: Verify and save the SSH host key
Do not make the workflow trust whatever SSH key it receives during deployment.
From a trusted computer, collect the server's public host keys:
ssh-keyscan \
-p YOUR_SSH_PORT \
YOUR_CPANEL_HOST > cpanel_known_hosts
Display their fingerprints:
ssh-keygen \
-lf cpanel_known_hosts \
-E sha256
Verify the fingerprint with your hosting provider or hosting control panel. After verification, copy the complete contents of cpanel_known_hosts.
If the hosting provider later changes the server's SSH host key, the deployment will fail until this value is updated. That failure is safer than silently trusting an unexpected server.
Step 7: Add GitHub secrets and variables
Open the repository:
Settings
→ Secrets and variables
→ Actions
Repository secrets
Add these secrets:
| Secret | Value |
|---|---|
CPANEL_SSH_PRIVATE_KEY |
Complete private deployment key |
CPANEL_KNOWN_HOSTS |
Verified contents of cpanel_known_hosts
|
Display the private key locally:
cat "$HOME/.ssh/deploy-keys/cpanel_production"
Copy the complete value, including:
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
Do not paste the .pub key into CPANEL_SSH_PRIVATE_KEY.
CPANEL_KNOWN_HOSTS is not confidential, but storing the multiline value as a secret is convenient and prevents accidental editing in the workflow file.
Repository variables
Add these variables:
| Variable | Example |
|---|---|
APP_DIR |
/home/exampleuser/example.com |
CPANEL_HOST |
server123.hosting-company.com |
CPANEL_PORT |
22 |
CPANEL_USER |
exampleuser |
PHP_BIN |
/opt/cpanel/ea-php84/root/usr/bin/php |
APP_URL |
https://example.com |
HEALTHCHECK_URL |
https://example.com/up |
APP_URL is the public application URL used during the frontend build. HEALTHCHECK_URL is optional; a lightweight public health endpoint is better than a page that requires authentication.
Optional production environment
On GitHub plans that support environments for private repositories, you can move the same secrets and variables into a protected environment named production. Then add this line to the deployment job:
environment: production
A protected environment can add approvals and restrict which branches may deploy. If your plan does not support those features for a private repository, repository-level secrets and variables still work.
Step 8: Add the GitHub Actions workflow
Create:
.github/workflows/deploy-cpanel.yml
Add the following workflow:
name: Deploy Laravel and Vue to cPanel
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: production-cpanel
cancel-in-progress: false
env:
APP_DIR: ${{ vars.APP_DIR }}
CPANEL_HOST: ${{ vars.CPANEL_HOST }}
CPANEL_PORT: ${{ vars.CPANEL_PORT || '22' }}
CPANEL_USER: ${{ vars.CPANEL_USER }}
PHP_BIN: ${{ vars.PHP_BIN }}
APP_URL: ${{ vars.APP_URL }}
HEALTHCHECK_URL: ${{ vars.HEALTHCHECK_URL }}
NODE_VERSION: "24"
NODE_OPTIONS: --max-old-space-size=4096
jobs:
build-and-deploy:
name: Build and deploy production
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: none
tools: composer:v2
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, bcmath, curl, fileinfo, gd, openssl, zip
- name: Install production PHP dependencies
env:
COMPOSER_ALLOW_SUPERUSER: "1"
run: |
composer validate --no-check-publish
composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress \
--optimize-autoloader
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install frontend dependencies
run: npm ci --no-audit --no-fund
- name: Build frontend assets
env:
VITE_APP_URL: ${{ vars.APP_URL }}
run: npm run build
- name: Verify the build
run: |
test -f artisan
test -f vendor/autoload.php
test -d public/build
test -f public/build/manifest.json
- name: Validate deployment configuration
env:
SSH_PRIVATE_KEY: ${{ secrets.CPANEL_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.CPANEL_KNOWN_HOSTS }}
run: |
set -Eeuo pipefail
: "${APP_DIR:?Missing APP_DIR variable}"
: "${CPANEL_HOST:?Missing CPANEL_HOST variable}"
: "${CPANEL_PORT:?Missing CPANEL_PORT variable}"
: "${CPANEL_USER:?Missing CPANEL_USER variable}"
: "${PHP_BIN:?Missing PHP_BIN variable}"
: "${SSH_PRIVATE_KEY:?Missing CPANEL_SSH_PRIVATE_KEY secret}"
: "${SSH_KNOWN_HOSTS:?Missing CPANEL_KNOWN_HOSTS secret}"
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.CPANEL_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.CPANEL_KNOWN_HOSTS }}
run: |
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/id_ed25519"
chmod 600 "$HOME/.ssh/id_ed25519"
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
- name: Package the application
run: |
tar -czf /tmp/app-deploy.tar.gz \
--exclude='./.git' \
--exclude='./.github' \
--exclude='./node_modules' \
--exclude='./.env' \
--exclude='./.env.*' \
--exclude='./storage' \
--exclude='./public/storage' \
--exclude='./public/hot' \
--exclude='./public/.well-known' \
--exclude='./.user.ini' \
--exclude='./php.ini' \
--exclude='./tests' \
.
- name: Deploy to cPanel
run: |
set -Eeuo pipefail
SSH=(
ssh
-i "$HOME/.ssh/id_ed25519"
-p "$CPANEL_PORT"
-o BatchMode=yes
-o ConnectTimeout=15
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$HOME/.ssh/known_hosts"
"$CPANEL_USER@$CPANEL_HOST"
)
SCP=(
scp
-i "$HOME/.ssh/id_ed25519"
-P "$CPANEL_PORT"
-o BatchMode=yes
-o ConnectTimeout=15
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$HOME/.ssh/known_hosts"
)
"${SSH[@]}" \
"mkdir -p '$APP_DIR' && test -f '$APP_DIR/.env' && test -x '$PHP_BIN'" || {
echo '::error::APP_DIR, production .env, or PHP_BIN is invalid.'
exit 1
}
"${SSH[@]}" \
"cd '$APP_DIR' && if [ -f artisan ]; then '$PHP_BIN' artisan down --retry=60 || true; fi"
bring_application_up() {
"${SSH[@]}" \
"cd '$APP_DIR' && if [ -f artisan ]; then '$PHP_BIN' artisan up; fi"
}
remote_update_started=0
handle_failure() {
status=$?
if [ "$status" -ne 0 ]; then
if [ "$remote_update_started" -eq 0 ]; then
bring_application_up || true
else
echo '::warning::Deployment failed after the remote update started.'
echo '::warning::The application may remain in maintenance mode for inspection.'
fi
fi
}
trap handle_failure EXIT
"${SCP[@]}" \
/tmp/app-deploy.tar.gz \
"$CPANEL_USER@$CPANEL_HOST:$APP_DIR/app-deploy.tar.gz"
remote_update_started=1
"${SSH[@]}" \
"APP_DIR='$APP_DIR' PHP_BIN='$PHP_BIN' bash -se" <<'REMOTE'
set -Eeuo pipefail
cd "$APP_DIR"
test -f app-deploy.tar.gz
tar -xzf app-deploy.tar.gz --overwrite
rm app-deploy.tar.gz
test -f .env
test -f artisan
test -f vendor/autoload.php
test -f public/build/manifest.json
mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
chmod -R ug+rwX storage bootstrap/cache
"$PHP_BIN" artisan package:discover --ansi
"$PHP_BIN" artisan config:clear
"$PHP_BIN" artisan route:clear
"$PHP_BIN" artisan view:clear
"$PHP_BIN" artisan event:clear || true
"$PHP_BIN" artisan migrate --force
if [ ! -L public/storage ]; then
"$PHP_BIN" artisan storage:link
fi
"$PHP_BIN" artisan optimize
"$PHP_BIN" artisan queue:restart || true
chmod -R ug+rwX storage bootstrap/cache
REMOTE
bring_application_up
trap - EXIT
- name: Check the production URL
if: ${{ vars.HEALTHCHECK_URL != '' }}
run: |
curl \
--fail \
--silent \
--show-error \
--location \
--retry 5 \
--retry-delay 5 \
--retry-connrefused \
--max-time 30 \
"$HEALTHCHECK_URL" > /dev/null
- name: Write deployment summary
run: |
{
echo '## Deployment completed'
echo
echo "- Directory: \`$APP_DIR\`"
echo '- Composer dependencies built on GitHub Actions'
echo '- Vue and Vite assets built on GitHub Actions'
echo '- Production .env and storage preserved'
} >> "$GITHUB_STEP_SUMMARY"
The example uses PHP 8.4 and Node.js 24. Change both versions to match your application and production server.
The workflow gives GITHUB_TOKEN only read access to repository contents. It also disables persisted checkout credentials because the remaining steps do not need authenticated Git commands.
Step 9: Run the first deployment
Commit the workflow:
git add .github/workflows/deploy-cpanel.yml
git commit -m "Add automatic cPanel deployment"
git push origin main
Open:
GitHub repository → Actions
Select the workflow and follow the logs.
After it finishes, verify the application from cPanel:
cd "$HOME/example.com"
PHP_BIN=/opt/cpanel/ea-php84/root/usr/bin/php
"$PHP_BIN" artisan about
"$PHP_BIN" artisan migrate:status
ls -la public/build
ls -la public/storage
Then open the production URL.
What the workflow preserves
The deployment archive excludes:
.env
.env.*
storage/
public/storage/
public/.well-known/
.user.ini
php.ini
This prevents the workflow from replacing:
- Production credentials and API keys.
- User-uploaded files.
- Application logs and sessions.
- The public storage symbolic link.
- Server-managed SSL challenge files.
- Hosting-specific PHP configuration files.
The workflow includes Composer's vendor directory and Vite's public/build directory because both are built on GitHub Actions before deployment.
Important security notes
Do not add a GitHub deploy key to cPanel for this workflow
The server does not run git pull. GitHub Actions checks out the repository and sends a prepared archive to cPanel.
Adding a cPanel-to-GitHub key would create an unnecessary credential on the production server. If the server were compromised, that credential could allow the attacker to clone the repository it belongs to.
Never put secrets in VITE_*
Values prefixed with VITE_ may become part of the browser's JavaScript bundle.
Safe examples:
VITE_APP_NAME
VITE_APP_URL
Unsafe examples:
VITE_DB_PASSWORD
VITE_STRIPE_SECRET
VITE_OPENAI_API_KEY
Only put public frontend configuration in VITE_* variables.
Protect the production workflow
A person who can modify a production workflow may try to use the secrets available to that workflow.
At minimum:
- Protect the
mainbranch. - Require pull requests and reviews.
- Require review for changes inside
.github/workflows/. - Consider a
CODEOWNERSrule for workflow files. - Use a protected
productionenvironment when your GitHub plan supports it. - Keep
permissions: contents: readunless more access is genuinely required. - Review third-party actions and consider pinning them to full commit SHAs.
- Use Dependabot to keep action versions updated.
Back up before risky migrations
The workflow runs:
php artisan migrate --force
A code rollback does not restore deleted or modified database data. Back up the database before destructive or difficult-to-reverse migrations.
queue:restart does not start a queue worker
The command only tells existing workers to exit gracefully after finishing their current job. A process manager, hosting feature, or scheduled command must already be running the workers.
On shared hosting, you may need a cPanel cron job instead of a continuously running worker.
Common errors
Permission denied (publickey)
Check that:
- The deployment public key was imported and authorized in cPanel.
- The matching private key was saved in
CPANEL_SSH_PRIVATE_KEY. -
CPANEL_USER,CPANEL_HOST, andCPANEL_PORTare correct. - The hosting account allows SSH access.
Test locally:
ssh \
-vvv \
-i "$HOME/.ssh/deploy-keys/cpanel_production" \
-p YOUR_SSH_PORT \
YOUR_CPANEL_USER@YOUR_CPANEL_HOST
Host key verification failed
The value in CPANEL_KNOWN_HOSTS does not match the server.
Do not disable strict host checking. Confirm whether the hosting provider intentionally changed the SSH host key, verify the new fingerprint, and then update the secret.
Production .env is missing
Create .env inside APP_DIR, not inside public:
cd /home/YOUR_CPANEL_USER/YOUR_APP_DIRECTORY
touch .env
chmod 600 .env
nano .env
public/build/manifest.json is missing
Run locally:
npm ci
npm run build
Confirm that Vite creates:
public/build/manifest.json
If your project uses a different Vite output directory, update the workflow checks.
Laravel returns HTTP 500
Check the log:
tail -n 100 storage/logs/laravel.log
Then inspect the environment, permissions, PHP extensions, database connection, and cached configuration.
If a failed deployment left the application in maintenance mode, bring it back only after fixing the problem:
"$PHP_BIN" artisan up
storage:link fails
Check whether public/storage already exists as a normal directory or whether the hosting provider blocks symbolic links:
ls -la public/storage
Remove or rename an incorrect directory only after confirming it does not contain files that must be preserved.
A note about deleted files
This workflow extracts the new archive over the existing application.
Files with matching names are replaced, but a file deleted from Git may remain on the server. This is one limitation of the simple archive-based approach.
For many small applications, that trade-off is acceptable. When the application grows, move to release directories and a current symbolic link:
releases/
├── 20260730-120001/
├── 20260730-123500/
└── 20260730-130800/
current -> releases/20260730-130800
That architecture makes atomic switching, cleanup, and code rollback easier, but it requires a more advanced deployment workflow and a shared storage directory.
Conclusion
Manual cPanel deployment works, but it becomes slow and error-prone when deployments are frequent.
With this setup, the regular process becomes:
git add .
git commit -m "Describe the change"
git push origin main
GitHub Actions will:
- Check out the private repository with its built-in token.
- Install Laravel's production dependencies.
- Build the Vue and Vite assets.
- Upload the application to cPanel through SSH.
- Preserve
.env, storage, uploads, logs, and server-managed files. - Run migrations and optimize Laravel.
- Signal existing queue workers to restart.
- Check the production website.
Most importantly, the cPanel server does not need access to the private GitHub repository. It only receives the deployment created by GitHub Actions.
The first setup requires care around SSH keys, host verification, server paths, PHP versions, and production secrets. After that, deployments become repeatable, predictable, and much less stressful.

Top comments (0)