CI/CD Standard Operating Procedure
Continuous Integration & Deployment with GitHub + cPanel — Uni B Agency Engineering Team
1. Philosophy & Non-Negotiables
The live server is read-only. GitHub is the single source of truth.
Every change — no matter how small — must flow through GitHub. A typo fix, a colour change, a config tweak: all of it goes through a commit, a pull request, and the automated pipeline. There are no exceptions.
Why This Matters
Direct server edits are the single biggest cause of:
- Production outages that cannot be traced or rolled back
- Security vulnerabilities introduced silently
- Code drift where the server no longer matches what is in version control
- Team confusion when developers pull “outdated” code
At Uni B Agency, we treat the live server the same way a pilot treats a plane’s controls — we follow a checklist, every time, without shortcuts.
The Three Guarantees This Process Gives Us
Every change has an author, a timestamp, and a reason.
Any broken deployment can be reversed with one git revert.
What runs locally is what runs in production.
2. Branching Strategy
We use a structured three-tier branching model.
main <-- production server deploys from here (PROTECTED)
^
develop <-- integration branch, staging deploys from here
^
feature/* <-- individual developer work
hotfix/* <-- emergency production patches only
main — Production Branch
- Protected. No one pushes directly to
main, ever. Not even the lead developer. - Reflects exactly what is running on the live server.
- Code enters
mainonly via a reviewed, approved Pull Request fromdevelop. - Every merge triggers an automatic deployment to production.
develop — Integration Branch
- Protected. No direct pushes.
- Where completed features are collected and tested before going to production.
- All developers merge their finished feature branches via Pull Requests.
- If a staging server exists,
developdeploys there automatically.
feature/[name] — Feature Branches
- Created by individual developers for each piece of work.
- Naming:
feature/task-description(e.g.feature/add-payment-gateway) - Always branched off
develop, never offmain. - Deleted after the PR is merged.
hotfix/[name] — Emergency Patches
- Used only when a critical bug on production must be fixed immediately.
- Branched off
main, merged into BOTHmainANDdevelopwhen complete. - Naming:
hotfix/brief-description(e.g.hotfix/login-redirect-broken)
Branch Protection Rules
Configure in GitHub: Repository → Settings → Branches
| Branch | Rules |
|---|---|
main | Require PR, require 1 approval, require status checks to pass, no force push, no deletion |
develop | Require PR, require status checks to pass, no force push |
3. Repository Setup
3.1 Creating a New Project Repository
All Uni B Agency project repositories are private by default, live under the UNI-B-AGENCY GitHub organisation, and follow the naming convention project-domain.
# On your local machine
cd /path/to/project
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin git@github.com:UNI-B-AGENCY/[project-name].git
git push -u origin main
# Create the develop branch immediately
git checkout -b develop
git push -u origin develop
3.2 Required Files in Every Repository
| File | Purpose |
|---|---|
.gitignore | Excludes secrets, vendor files, logs, OS files |
.env.example | Documents all required env variables (no real values) |
.cpanel.yml | Tells cPanel what commands to run after deployment |
.github/workflows/deploy.yml | GitHub Actions CI/CD pipeline |
README.md | Project description, local setup instructions |
3.3 Standard .gitignore Entries
# Secrets -- never commit these
.env
.env.*
!.env.example
# Dependencies -- installed on deploy
/vendor
/node_modules
# Laravel runtime files
/bootstrap/cache/*
/storage/*.key
/storage/installed
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/storage/logs/*
# Server-specific files -- never commit these
/cgi-bin/
error_log
*.sql
/public/user-uploads/*
!public/user-uploads/.htaccess
# Local dev tools
/.cursor/
/.idea/
/.vscode/
*.code-workspace
.DS_Store
Thumbs.db
4. cPanel Server Setup
One-time setup per project, performed by the lead developer.
4.1 Document Root Configuration
Critical for Laravel projects
The web server must serve from public/, not the project root. Failure to do this exposes your .env file and source code to the internet.
- cPanel → Domains (or Subdomains)
- Find your domain → click Manage
- Set Document Root to:
/home/[cpanel-username]/[project-folder]/public - Save
4.2 SSH Key for GitHub Access
Run in cPanel Terminal:
# Generate a dedicated deploy key (no passphrase -- required for automation)
ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""
# Create SSH config so git uses this key for GitHub
cat > ~/.ssh/config << 'EOF'
Host github.com
IdentityFile ~/.ssh/github_deploy
User git
EOF
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/github_deploy
# Print the public key to add to GitHub
cat ~/.ssh/github_deploy.pub
In GitHub: Repository → Settings → Deploy keys → Add deploy key. Title: cPanel Production Server. Do NOT grant write access.
ssh -T git@github.com
# Expected: Hi UNI-B-AGENCY/[repo]! You've successfully authenticated...
4.3 Clone via cPanel Git Version Control
- cPanel → Files → Git™ Version Control
- Click Create → enable Clone a Repository
- Clone URL:
git@github.com:UNI-B-AGENCY/[repo].git - Repository Path:
/home/[cpanel-username]/[project-folder](use a name that does NOT match the subdomain folder) - Click Create and accept the GitHub SSH host key
4.4 SSH Key for GitHub Actions
# Generate a separate key for GitHub Actions
ssh-keygen -t ed25519 -f ~/.ssh/github_actions_deploy -N ""
# Authorise it for SSH login
cat ~/.ssh/github_actions_deploy.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
# Print the PRIVATE key -- this goes into GitHub Secrets
cat ~/.ssh/github_actions_deploy
Add to GitHub Secrets: Repository → Settings → Secrets and variables → Actions
| Secret Name | Value |
|---|---|
SSH_HOST | Your server hostname (e.g. s10701.hostingprovider.com) |
SSH_USERNAME | Your cPanel username (e.g. unibagency) |
SSH_PRIVATE_KEY | The entire private key including -----BEGIN and -----END lines |
SSH_PORT | 22 (confirm with your host if different) |
4.5 Initial Laravel Setup on the Server
Run once after the first clone, before the site goes live:
cd ~/[project-folder]
# Create production environment file
cp .env.example .env
nano .env # Fill in all production values
# Install dependencies
/usr/local/bin/composer install --no-dev --optimize-autoloader
# Generate application key
php artisan key:generate
# Run database migrations
php artisan migrate --force
# Create storage symlink
php artisan storage:link
# Cache configuration for performance
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Set permissions
chmod -R 775 storage bootstrap/cache
5. GitHub Actions CI/CD Pipeline
5.1 Standard Workflow File
Save as .github/workflows/deploy.yml in every project:
name: Deploy to Production
on:
push:
branches:
- main # Only deploys when code reaches main
jobs:
deploy:
name: SSH Deploy to cPanel
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e # Stop on any error
cd ~/[project-folder]
# Force-align server with GitHub (discards any server-side drift)
git fetch origin
git reset --hard origin/main
# Install/update PHP dependencies
/usr/local/bin/composer install --no-dev --optimize-autoloader
# Maintenance mode: allow only server IP
php artisan down --allow=127.0.0.1
# Run any pending database migrations
php artisan migrate --force
# Rebuild caches
php artisan config:cache
php artisan route:cache
mkdir -p resources/views/modules/sms
php artisan view:cache
# Ensure storage link exists
php artisan storage:link --force
# Bring site back online
php artisan up
echo "Deployment complete."
5.2 What Each Step Does
| Step | Purpose |
|---|---|
git reset --hard origin/main | Forces the server to match GitHub exactly — prevents local drift |
composer install --no-dev | Installs PHP packages, skipping development-only tools |
artisan down | Shows a maintenance page to users during deployment |
artisan migrate --force | Applies any new database schema changes |
artisan config:cache | Compiles .env into a cached file for faster performance |
artisan route:cache | Compiles all routes into a single cached file |
artisan view:cache | Pre-compiles all Blade templates |
artisan up | Brings the site back online |
5.3 The set -e Directive
set -e means: if any command fails, stop immediately. This prevents a broken migration from being followed by a cache rebuild that hides the error. Always keep this line.
6. The Deployment Workflow
This is the day-to-day process every developer at Uni B Agency follows.
Normal Feature Development
git checkout develop && git pull origin developgit checkout -b feature/your-feature-namegit add [specific files]
git commit -m "Short description of what and why"git push origin feature/your-feature-nameBase: develop ← Compare: feature/your-feature-name. Add a clear description of what changed and why.
Releasing to Production
Commit Message Standard
[type]: short description (max 72 characters)
Optional: longer explanation of WHY this change was made.
| Type | When to use |
|---|---|
feat: | New feature |
fix: | Bug fix |
style: | CSS/UI change with no logic change |
refactor: | Code restructure, no behaviour change |
docs: | Documentation only |
chore: | Dependency updates, config changes |
hotfix: | Emergency production fix |
feat: add phone and email to landing page footer
fix: correct task submission file URL using asset_url_local_s3
hotfix: resolve login redirect loop on production
chore: update composer dependencies for security patches
7. Security Standards
Security is not optional. Every developer is responsible for the security of code they commit.
7.1 Secrets Management
Never commit secrets.
Database passwords, API keys, app encryption keys (APP_KEY), payment credentials, SMTP passwords, and any third-party service credentials must never be committed.
All secrets live in the .env file on the server. The .env file is in .gitignore and must never be committed. For GitHub Actions, use GitHub’s encrypted Secrets — never hardcode values in the workflow YAML.
7.2 API Token Rotation
cPanel API tokens must be rotated every 90 days or immediately after any team member leaves.
- cPanel → Manage API Tokens → Create new token
- Update the GitHub Secret with the new token
- Delete the old token in cPanel
- Confirm the next deployment succeeds before closing the session
7.3 Server File Monitoring
Periodically scan the server for files that do not belong:
# Find PHP files in non-PHP directories (potential webshells)
find ~/[project]/public/css -name "*.php"
find ~/[project]/public/js -name "*.php"
find ~/[project]/documentation -name "*.php"
# Find recently modified PHP files (last 7 days)
find ~/[project]/public -name "*.php" -mtime -7
# Find world-writable files (security risk)
find ~/[project] -perm -002 -type f
Red flags — investigate immediately:
- PHP files inside
css/,js/,img/, ordocumentation/directories - Short random filenames like
n33.php,ws87.php,x7.php - Text files like
trb.txt,test.txtin the public directory - Files containing
eval(,base64_decode(,system(,exec(
If suspicious files are found:
- Read the file content before deleting
- If it contains obfuscated code — change all passwords immediately (cPanel, database, GitHub, email)
- Check
.htaccessfiles for injected redirect rules - Delete the files and audit server access logs
- Report to the team lead
7.4 Production Environment Settings
APP_ENV=production
APP_DEBUG=false # NEVER true on production -- exposes stack traces
APP_URL=https://your-domain.com
REDIRECT_HTTPS=true
APP_DEBUG=true on a live server exposes database credentials, file paths, and internal logic to anyone who triggers an error. This is a critical vulnerability.
7.5 File Permissions
# Application directories
chmod -R 755 ~/[project]
# Storage and cache -- must be writable
chmod -R 775 ~/[project]/storage
chmod -R 775 ~/[project]/bootstrap/cache
# Environment file -- owner only
chmod 600 ~/[project]/.env
7.6 Dependency Security
# Check for known PHP vulnerabilities
composer audit
# Check for known JS vulnerabilities
npm audit
Block any merge that introduces a package with a critical severity vulnerability.
8. Environment Configuration
8.1 The Environment Hierarchy
| Environment | Branch | Server | .env managed by |
|---|---|---|---|
| Local development | feature/* | Developer’s machine | Developer |
| Staging (if available) | develop | Staging cPanel | Lead developer |
| Production | main | Production cPanel | Lead developer only |
8.2 What Lives in .env.example
This file is the contract. It must list every variable the application needs with safe placeholder values. New developers cp .env.example .env and fill in their local values.
APP_NAME=ProjectName
APP_KEY= # Generate with: php artisan key:generate
APP_ENV=local # Change to: production on server
APP_DEBUG=true # Change to: false on server
APP_URL=http://localhost
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database_name
DB_USERNAME=your_db_user
DB_PASSWORD=
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@unibagency.com
MAIL_FROM_NAME="${APP_NAME}"
8.3 Production .env Handoff
When a new project goes live, the lead developer:
- Sets the production
.envon the server directly via cPanel Terminal - Runs
php artisan config:cacheto apply it - Does not share the production
.envin Slack, email, or any chat system - Stores it securely in the team’s password manager (e.g. Bitwarden, 1Password)
9. Emergency Hotfix Procedure
When production is broken and cannot wait for the normal cycle:
# 1. Branch off main (NOT develop)
git checkout main
git pull origin main
git checkout -b hotfix/brief-description
# 2. Make the minimum fix required -- nothing more
# 3. Commit with a clear message
git add [specific files]
git commit -m "hotfix: [description of what was broken and what was fixed]"
# 4. Push and open a PR directly to main
git push origin hotfix/brief-description
# Open PR: main <-- hotfix/brief-description
# Get one approval (can be fast-tracked in a genuine emergency)
# 5. Merge to main -- triggers automatic deployment
# Verify the fix on the live site
# 6. IMPORTANT: merge the hotfix into develop too
git checkout develop
git pull origin develop
git merge hotfix/brief-description
git push origin develop
# 7. Delete the hotfix branch
git branch -d hotfix/brief-description
git push origin --delete hotfix/brief-description
Step 6 is mandatory.
If you do not merge the hotfix into develop, the next release from develop → main will revert your fix.
10. Onboarding a New Project
Checklist for setting up a brand new project under this CI/CD standard:
- Create private repository under UNI-B-AGENCY organisation
- Add
.gitignore,.env.example,README.mdin first commit - Create and protect both
mainanddevelopbranches - Set up cPanel subdomain with correct document root (pointing to
/public) - Generate GitHub deploy key on cPanel, add to GitHub Deploy Keys
- Clone repository via cPanel Git Version Control
- Create
.cpanel.ymlwith deployment tasks - Run initial server setup (
composer install,migrate,storage:link) - Generate GitHub Actions SSH key, add to
authorized_keys - Add
SSH_HOST,SSH_USERNAME,SSH_PRIVATE_KEY,SSH_PORTto GitHub Secrets - Create
.github/workflows/deploy.yml - Push to main, verify GitHub Action runs green
- Spot-check live site
- Document project-specific notes in
README.md - Scan server for unexpected files one week after launch
11. Onboarding a New Developer
What they receive
- Read access to the GitHub repository
- Local
.envvalues from the lead developer (never production values) - This document
What they never receive
- Production
.envcontents - cPanel login credentials
- Server SSH private keys
- GitHub API tokens with write access to
main
GitHub Authentication Setup (Do This First)
Before you can push or clone, your local machine needs to authenticate with GitHub. Choose one method:
Method A — SSH Key (Recommended for daily use)
# 1. Check if you already have an SSH key
cat ~/.ssh/id_ed25519.pub
# If it prints a key starting with ssh-ed25519, copy it and skip to step 3.
# If you get "No such file or directory", generate one:
# 2. Generate a new SSH key
ssh-keygen -t ed25519 -C "your_email@example.com"
# Press Enter through all prompts to accept the default location
# 3. Print the public key and copy the output
cat ~/.ssh/id_ed25519.pub
In GitHub: click your profile icon → Settings → SSH and GPG keys → New SSH key. Paste the key and save.
Use the SSH clone URL: git@github.com:UNI-B-AGENCY/[repo].git
Method B — HTTPS + Personal Access Token (Simpler to start)
Important: GitHub no longer accepts your account password for Git operations. You must use a Personal Access Token (PAT) instead.
Generate a PAT: GitHub → Profile icon → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token. Grant the repo scope. Save the token — you will not see it again.
Use the HTTPS clone URL: https://github.com/UNI-B-AGENCY/[repo].git. When Git prompts for a password, enter your PAT, not your GitHub password.
Local Setup
# Clone the repository (SSH)
git clone git@github.com:UNI-B-AGENCY/[repo].git
# -- OR -- clone via HTTPS (if using Method B above)
git clone https://github.com/UNI-B-AGENCY/[repo].git
cd [repo]
# Set up environment
cp .env.example .env
# Fill in local database and other local credentials
# Install dependencies
composer install
npm install
# Set up the database
php artisan migrate
php artisan db:seed # if seeders exist
# Start the local server
php artisan serve
Daily Workflow Reminder
Pull develop → Create feature branch → Work → Commit → Push → Open PR → Get reviewed → Merge to develop
They never touch main directly. Ever.
12. Forbidden Practices
The following actions are prohibited at Uni B Agency. Violation will result in an incident review.
| Forbidden Action | Why |
|---|---|
| Editing files directly on the live server (via File Manager, FTP, or nano) | Creates untracked changes that break future deployments and cannot be rolled back |
Committing .env files | Exposes credentials to anyone with repository access |
Committing vendor/ or node_modules/ | Creates enormous repositories and masks dependency version issues |
Force-pushing to main or develop | Can permanently erase teammates’ work and the deployment history |
| Sharing production credentials in Slack, WhatsApp, or email | Credentials shared in plain text are credentials compromised |
| Deploying on a Friday afternoon | No coverage if something breaks over the weekend |
| Skipping code review for “small” changes | The most catastrophic bugs are the ones that looked small |
Using APP_DEBUG=true on a live server | Exposes stack traces, file paths, and database credentials to the public |
Committing database dumps (.sql files) | Contains real user data; violates data privacy obligations |
Running php artisan migrate:fresh on production | Drops all tables and destroys all data — irreversible |
13. Troubleshooting Reference
GitHub Actions is green but server code did not update
Cause: Uncommitted changes on the server blocked git pull.
cd ~/[project] && git status
git fetch origin
git reset --hard origin/main
Re-trigger the deployment after the fix. This is why git reset --hard origin/main is in the standard deploy script.
GitHub Actions fails with Permission denied (publickey)
Cause: The SSH private key in GitHub Secrets does not match the public key in ~/.ssh/authorized_keys on the server.
- On the server:
cat ~/.ssh/authorized_keys— confirm your key is there - In GitHub Secrets: confirm
SSH_PRIVATE_KEYcontains the matching private key including header/footer lines
cPanel API returns 415 Unsupported Media Type
Cause: Sending data as a POST form body. cPanel’s UAPI expects GET with query parameters.
GET https://[host]:2083/execute/VersionControl/update?repository_root=/home/[user]/[project]
cPanel Deploy button is greyed out
Cause: Either .cpanel.yml is missing or there are uncommitted changes on the server.
cat ~/[project]/.cpanel.yml # Must exist
git status # Must show "nothing to commit"
php artisan view:cache fails with “directory does not exist”
Cause: A module references a view directory not yet created on the server.
mkdir -p resources/views/modules/sms
php artisan view:cache
The GET method is not supported for route /
Cause: Route cache is stale or corrupt after a failed partial deployment.
php artisan route:clear
php artisan config:clear
php artisan cache:clear
# Then re-run caches:
php artisan config:cache
php artisan route:cache
php artisan view:cache
Storage link error: [public/storage] link already exists
This is not an error — it is a warning. The --force flag handles it:
php artisan storage:link --force
Already included in the standard deploy script.
Application shows the install wizard on a fresh deploy
Cause: The storage/installed flag file does not exist. It is in .gitignore by design and must be created manually on each new server.
# Run once per server -- never commit
touch ~/[project]/storage/installed
php artisan config:clear
Permission denied (publickey) when pushing from your local machine
Cause: GitHub does not recognise your local SSH key. This is different from the GitHub Actions → cPanel error above; this happens on a developer’s own machine.
Fix A — Add your SSH key to GitHub:
# Check if you have an existing key
cat ~/.ssh/id_ed25519.pub
# If "No such file or directory", generate one first:
ssh-keygen -t ed25519 -C "your_email@example.com"
# Then print it:
cat ~/.ssh/id_ed25519.pub
Copy the output. Go to GitHub → Settings → SSH and GPG keys → New SSH key, paste it, and save. Then retry your push.
Fix B — Switch to HTTPS and use a Personal Access Token:
# Switch your remote from SSH to HTTPS
git remote set-url origin https://github.com/UNI-B-AGENCY/[repo].git
# Push (GitHub will prompt for credentials)
git push -u origin main
When Git prompts for a password, use a Personal Access Token (PAT) — not your GitHub account password. Generate one at: GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic). Grant the repo scope.
fatal: remote origin already exists
Cause: You ran git remote add origin but a remote named origin is already set (e.g. pointing at the wrong URL or a typo in the repo name).
Fix — update the URL instead of adding:
# Use set-url to overwrite the existing remote
git remote set-url origin git@github.com:UNI-B-AGENCY/[repo].git
# Verify it is correct
git remote -v
# Then push
git push -u origin main
Common mistake: a typo in the repo name (e.g. devedocs instead of devdocs). Always double-check the URL with git remote -v before pushing.
This document is a living standard. Propose changes via a Pull Request to the documentation branch with a clear explanation. All changes require sign-off from the Engineering Lead.