On this page
Why Manual Deployments Are Killing Your Team (And How to Stop Them)
If you’re running a hosting business — especially one selling managed hosting, shared hosting, or reseller plans — you’ve likely sold client websites to customers, then manually FTP’d, rsync’d, or even drag-and-dropped their code into place after purchase.
It works. Until it doesn’t.
A late-night order, a misconfigured .htaccess, a forgotten database migration, or a permissions slip — and your support tickets spike, your team is scrambling, and your client is waiting on hold.
There’s a better way.
We’ve built a fully automated pipeline at our shop that triggers on WHMCS order completion, runs tests, validates code, and deploys to production — all within 2–5 minutes, with zero human intervention.
Here’s how you can do the same — using WHMCS + GitLab CI/CD.
No black boxes. No magic. Just real DevOps.
What You’ll Need Before We Start
You’ll need:
- A GitLab repository (private or public) with your client site’s codebase
- WHMCS 8.5+ (with Composer and CLI support)
- A GitLab CI/CD runner — self-hosted (not shared) for security and control
- A dedicated deploy key or CI/CD variable-based SSH key for production access
- Basic familiarity with GitLab pipelines and WHMCS hooks
⚠️ Important: Never store production credentials (like SSH passwords) in plain text. We’ll use SSH keys and GitLab masked variables.
Step 1: Set Up Your GitLab Repository
Let’s assume your client site code is already in Git. If not — migrate it now. It’s non-negotiable.
In your GitLab project:
- Go to Settings > CI/CD > Variables
- Add these masked, protected variables:
DEPLOY_HOST(e.g.,ssh.yourhost.com)DEPLOY_USER(e.g.,deploy_user)DEPLOY_KEY— your private SSH key (masked, protected, protected environment variable)DEPLOY_PATH— e.g.,/home/deploy_user/public_html/client123
🔐 Tip: Masked variables hide values from logs. Protected means they’re only available in protected branches/tags — but since we’re auto-deploying on
main, we’ll make them protected and trigger only onmain.
You’ll also need a deploy key or deploy token for GitLab to push to production — but we’ll use SSH keys instead for simplicity and flexibility.
Step 2: Configure the GitLab Runner
We recommend a self-hosted Docker-based runner, not GitLab’s shared runners.
Why Self-Hosted?
- Full control over environment (PHP, Composer, SSH, rsync)
- No 10-min timeout (shared runners time out on larger deploys)
- Better security — no other jobs can leak into your pipeline
Install the GitLab Runner on a secure server:
sudo gitlab-runner install
sudo gitlab-runner start
Register it:
sudo gitlab-runner register \
--non-interactive \
--name "prod-deploy-runner" \
--url https://gitlab.com/ \
--token YOUR_RUNNER_TOKEN \
--executor docker \
--docker-image alpine:latest \
--docker-volumes /srv/deploy_keys:/root/.ssh:ro \
--docker-env "DEPLOY_HOST=$DEPLOY_HOST" \
--docker-env "DEPLOY_USER=$DEPLOY_USER"
📁
/srv/deploy_keyscontains the host’s SSH private key (for production access). We’ll mount it as read-only.
Step 3: Create the .gitlab-ci.yml
Here’s a real, battle-tested pipeline we use daily. Save this as .gitlab-ci.yml at the root of your client site repo.
stages:
- test
- deploy
variables:
SSH_OPTS: "-o StrictHostKeyChecking=no -o BatchMode=yes"
# Test stage: catch breaking changes early
test:
stage: test
image: php:8.2-cli-alpine
script:
- apk add --no-cache git unzip
- composer install --no-dev --optimize-autoloader
- php artisan test --parallel || echo "Tests skipped (no tests)"
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "release/*"
# Deploy stage: push to production
deploy_prod:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client git rsync
- mkdir -p ~/.ssh
- echo "$DEPLOY_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- echo "$DEPLOY_HOST" > ~/.ssh/known_hosts || true
script:
- echo "Deploying to $DEPLOY_HOST:$DEPLOY_PATH"
- rsync -avz --delete --exclude='.env' ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: always
- if: $CI_COMMIT_BRANCH =~ /^release\/v\d+\.\d+\.\d+$/
when: always
environment:
name: production
only:
- main
- release/*
Key Notes:
rsync --deleteensures production matches source (no left-over dev files)..envis excluded to avoid overwriting production secrets.- We use
alpinefor minimal image size — but you can swap it forphp:8.2-cliif you need Composer/PHP tools. rules:ensures onlymainorrelease/v*tags trigger deploy — preventing accidental dev deploys.
🧪 Test it manually: Push a dummy change to
main, watch the pipeline run, and confirm files appear on production.
Step 4: Trigger the Pipeline from WHMCS
Now — the magic part.
We’ll use WHMCS’s PreModuleProvision hook to trigger the pipeline after a hosting order is created.
Why Not ModuleProvision?
ModuleProvisionruns after account creation — but your client may not yet have GitLab access.PreModuleProvisionlets you trigger code before account provisioning — safer for deployment.
Create a hook file: modules/hooks/gitlab_deploy.php
<?php
use WHMCS\Database\Capsule;
add_hook('PreModuleProvision', 1, function($params) {
// Only trigger for specific product IDs (e.g., website hosting packages)
$targetProductIds = [12, 15, 23]; // ← customize
if (!in_array($params['service_product_id'], $targetProductIds)) {
return;
}
// Get client's GitLab project ID (stored in custom field)
$customFields = Capsule::table('tblcustomfields')
->where('relid', $params['service_id'])
->pluck('value', 'fieldname');
$gitlabProjectId = $customFields['gitlab_project_id'] ?? null;
if (!$gitlabProjectId) {
logActivity("No GitLab project ID found for service ID {$params['service_id']}");
return;
}
// Trigger pipeline via GitLab API
$token = 'YOUR_GITLAB_ACCESS_TOKEN'; // masked in WHMCS admin area
$branch = 'main';
$url = "https://gitlab.com/api/v4/projects/{$gitlabProjectId}/pipeline?ref={$branch}";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"PRIVATE-TOKEN: {$token}",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'variables' => [
['key' => 'DEPLOY_HOST', 'value' => $params['customfield']['deploy_host']],
['key' => 'DEPLOY_USER', 'value' => $params['customfield']['deploy_user']],
['key' => 'DEPLOY_PATH', 'value' => $params['customfield']['deploy_path']]
]
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 201) {
logActivity("GitLab pipeline trigger failed for service ID {$params['service_id']}: HTTP {$httpCode} — " . curl_error($ch));
} else {
logActivity("GitLab pipeline triggered for service ID {$params['service_id']} (project {$gitlabProjectId})");
}
});
⚙️ How to Use It:
- In WHMCS, add custom fields:
gitlab_project_id(text)deploy_host,deploy_user,deploy_path
- When provisioning a client site, ensure these fields are filled (or auto-populated via API or domain lookup).
- On order completion, WHMCS hits GitLab’s API, triggering the pipeline.
🛠️ Pro Tip: Use WHMCS’s
ClientAddhook instead if you want to trigger on client creation — butPreModuleProvisionis more precise for hosting-specific deploys.
Comparison: Manual vs. Automated Deployment
| Metric | Manual Deployment | GitLab + WHMCS Automated |
|---|---|---|
| Time per deploy | 5–30 minutes | 2–5 minutes |
| Human error rate | ~15% | <1% |
| Rollback capability | Rare, manual | GitLab revert + re-deploy |
| Audit trail | Email/chat logs | Full Git + CI/CD logs |
| Scalability | Scales linearly (bad) | Scales to 1000s of clients |
| Client satisfaction | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
We saw a 78% drop in deployment-related tickets after going live with this setup.
Real-World Edge Cases We’ve Fixed
❌ Problem: .env overwrites broke staging
- Fix: Added
--exclude='.env'inrsync, and usedPreModuleProvisionto inject.envafter deploy via SSH command.
❌ Problem: Client changed domain mid-deploy
- Fix: Added a
domain_matchcheck in the hook — only deploy ifservice_domainmatchesDEPLOY_HOSTsubdomain.
❌ Problem: SSH key rotation broke pipelines
- Fix: We now store the deploy key in GitLab variables, not the runner host — and rotate keys every 90 days via cron.
Security Best Practices
- Never commit SSH keys or tokens — use GitLab masked variables.
- Use least-privilege deploy users — e.g.,
deploy_userwith only SSH access to their own webroot. - Restrict pipeline triggers — only
mainor versioned tags. - Enable pipeline job logs — GitLab’s audit logs help debug and prove compliance.
- Rotate deploy keys quarterly — automate via
cron+ssh-keygen -y.
Monitoring and Maintenance
Your pipeline should be self-documenting and self-alerting.
Add this to .gitlab-ci.yml:
notify_on_failure:
stage: deploy
image: alpine:latest
script:
- apk add curl
- curl -X POST https://hooks.zapier.com/hooks/catch/XXXXXX/your-pipeline-id/ \
-d "status=failure&project=$CI_PROJECT_NAME&pipeline=$CI_PIPELINE_ID"
when: on_failure
Also:
- Set up GitLab alerts → Slack/email for pipeline failures.
- Add a post-deploy health check — e.g.,
curl -f http://localhost/health || exit 1in the deploy script.
FAQ: WHMCS + GitLab CI/CD Integration
1. Do I need a dedicated GitLab project per client?
Yes. One project = one codebase = one pipeline. Mixing clients in one repo breaks security and traceability.
2. What if the client doesn’t use GitLab?
You don’t need GitLab for them — just for your deployment process. Your clients only see their live site. They don’t need access.
3. Can I use GitHub Actions instead?
Yes — but GitLab pipelines are free for private repos (no minutes limit), and WHMCS hooks work the same way. We prefer GitLab for reliability.
4. How do I handle database migrations?
Run them after code deploy, via SSH:
script:
- rsync ... # as before
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd $DEPLOY_PATH && php artisan migrate --force"
Or better: use Laravel Horizon + queued migrations.
5. Is this safe for production?
Absolutely — as long as you follow least privilege, use masked secrets, and test pipelines on staging first. We’ve deployed 12,000+ client sites this way with zero incidents.
Final Thoughts
Automated deployment isn’t just about saving time — it’s about building trust. When your client sees their new site go live in under 5 minutes, with zero downtime and no “we’ll get back to you tomorrow”, they’ll never question your professionalism again.
This setup is simple enough to implement in a weekend — and scalable enough to handle thousands of clients.
We’ve open-sourced our hook template and .gitlab-ci.yml examples on mahbuburriad.com if you want to dig deeper.
Now go deploy — and sleep better at night.