Site Deployments
Use this skill when working with site deployments, rollbacks, or deployment infrastructure. Automatically invoked for implementing deployment features, understanding the symlink-based deployment architecture, or troubleshooting deployment issues. Triggered by prompts like "how do deployments work", "implement deployment feature", "add rollback", or any deployment-related questions.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: Site Deployments
description: Use this skill when working with site deployments, rollbacks, or deployment infrastructure. Automatically invoked for implementing deployment features, understanding the symlink-based deployment architecture, or troubleshooting deployment issues. Triggered by prompts like "how do deployments work", "implement deployment feature", "add rollback", or any deployment-related questions.
allowed-tools: Bash(php artisan*), Bash(vendor/bin/pint*), Read, Write, Edit, Glob, Grep, mcp__laravel-boost__*
---
# Site Deployments - Symlink-Based Architecture
## Overview
BrokeForge uses a **symlink-based deployment architecture** that enables:
- **Zero-downtime deployments** via atomic symlink swaps
- **Instant rollbacks** to any previous deployment
- **Automatic cleanup** of old deployments (keeps last 14)
- **Shared persistent data** across deployments (storage, .env, vendor, node_modules)
## Remote Directory Structure
Every site on a remote server follows this structure:
```
/home/brokeforge/
├── deployments/
│ └── {domain}/
│ ├── shared/
│ │ ├── storage/ # Laravel storage (persists across deployments)
│ │ ├── .env # Environment file (persists across deployments)
│ │ ├── vendor/ # Composer dependencies (persists)
│ │ └── node_modules/ # NPM dependencies (persists)
│ ├── 31072025-143022/ # Deployment timestamp (ddMMYYYY-HHMMSS)
│ │ ├── deployment.log # Deployment log for this deployment
│ │ ├── public/
│ │ ├── app/
│ │ └── ... (code and symlinks)
│ ├── 31072025-154530/ # Another deployment
│ │ ├── deployment.log
│ │ └── ... (code and symlinks)
│ └── 01082025-091245/ # Active deployment
│ ├── deployment.log
│ └── ... (code and symlinks)
└── {domain} → deployments/{domain}/01082025-091245/ # Symlink to active deployment
```
**Nginx Configuration:**
- Document root: `/home/brokeforge/{domain}/public`
- The domain path itself is a symlink that points to the active deployment
**Within Each Deployment Directory:**
```
/home/brokeforge/deployments/{domain}/01082025-091245/
├── deployment.log # Deployment log (automatically deleted when pruned)
├── public/
├── app/
├── storage → ../shared/storage # Symlink to shared
├── .env → ../shared/.env # Symlink to shared
├── vendor → ../shared/vendor # Symlink to shared
├── node_modules → ../shared/node_modules # Symlink to shared
└── ... (full git repository)
```
## Site Provisioning Flow
**File:** `app/Packages/Services/Sites/ProvisionedSiteInstaller.php`
### When a Site is Created:
1. **Creates directory structure:**
```bash
mkdir -p /home/brokeforge/{domain}/deployments
mkdir -p /home/brokeforge/{domain}/shared/storage
mkdir -p /home/brokeforge/{domain}/shared/vendor
mkdir -p /home/brokeforge/{domain}/shared/node_modules
touch /home/brokeforge/{domain}/shared/.env
```
2. **Generates nginx config** pointing to `/home/brokeforge/{domain}/public`
3. **If Git repository configured:**
- Generates deployment timestamp: `ddMMYYYY-HHMMSS` (e.g., `31072025-143022`)
- Clones to `/home/brokeforge/deployments/{domain}/{timestamp}/`
- Creates shared symlinks inside deployment:
- `{timestamp}/storage → ../shared/storage`
- `{timestamp}/.env → ../shared/.env`
- `{timestamp}/vendor → ../shared/vendor`
- `{timestamp}/node_modules → ../shared/node_modules`
- Creates site symlink: `/home/brokeforge/{domain}` → `deployments/{domain}/{timestamp}`
- Creates initial `ServerDeployment` record with:
- `status: 'success'`
- `deployment_path: '/home/brokeforge/deployments/{domain}/{timestamp}'`
- `commit_sha` (captured from git)
- Sets `site.active_deployment_id` to the initial deployment
4. **If no Git repository:**
- Creates placeholder in `deployments/{domain}/{timestamp}/public/index.php`
- Creates site symlink anyway for consistency
## Deployment Flow
**Files:**
- `app/Http/Controllers/ServerSiteDeploymentsController.php` - Controller
- `app/Packages/Services/Sites/Deployment/SiteGitDeploymentJob.php` - Job
- `app/Packages/Services/Sites/Deployment/SiteGitDeploymentInstaller.php` - Installer
### When User Clicks "Deploy Now":
**Step 1: Controller (`ServerSiteDeploymentsController@deploy`)**
```php
// ✅ CREATE RECORD FIRST (Reverb Package Lifecycle Pattern)
$deployment = ServerDeployment::create([
'server_id' => $server->id,
'server_site_id' => $site->id,
'status' => 'pending',
'deployment_script' => $site->getDeploymentScript(),
]);
// ✅ THEN dispatch job with deployment record
SiteGitDeploymentJob::dispatch($server, $deployment);
```
**Step 2: Job (`SiteGitDeploymentJob`)**
- Follows Reverb Package Lifecycle Pattern
- Updates status: `pending → updating → success/failed`
- Model events broadcast changes via Laravel Reverb
- Calls `SiteGitDeploymentInstaller->execute()`
**Step 3: Installer (`SiteGitDeploymentInstaller`)**
The installer executes these commands on the remote server:
```bash
# 1. Generate deployment timestamp (ddMMYYYY-HHMMSS)
timestamp=$(date +%d%m%Y-%H%M%S) # Example: 31072025-143022
# 2. Create deployment directory for THIS deployment
mkdir -p /home/brokeforge/deployments/{domain}/{timestamp}
# 3. Clone repository to the new deployment directory
GIT_SSH_COMMAND="..." git clone -b {branch} {repository} \
/home/brokeforge/deployments/{domain}/{timestamp}
# 4. Create shared directory symlinks
ln -sfn ../shared/storage /home/brokeforge/deployments/{domain}/{timestamp}/storage
ln -sfn ../shared/.env /home/brokeforge/deployments/{domain}/{timestamp}/.env
ln -sfn ../shared/vendor /home/brokeforge/deployments/{domain}/{timestamp}/vendor
ln -sfn ../shared/node_modules /home/brokeforge/deployments/{domain}/{timestamp}/node_modules
# 5. Run deployment script (default: "git fetch && git pull")
cd /home/brokeforge/deployments/{domain}/{timestamp}
{each line of deployment script}
# 6. Capture commit SHA
git rev-parse HEAD
# 7. ATOMIC SYMLINK SWAP (zero-downtime!)
ln -sfn deployments/{domain}/{timestamp} /home/brokeforge/{domain}
# 8. Reload PHP-FPM
sudo service php{version}-fpm reload
# 9. Update database
# - deployment.deployment_path = '/home/brokeforge/deployments/{domain}/{timestamp}'
# - deployment.status = 'success'
# - site.active_deployment_id = {deployment_id}
```
**Step 4: Auto-Prune Old Deployments**
After successful deployment, the installer automatically prunes old deployments:
- Keeps last **14** deployments
- Deletes deployment directories from remote: `rm -rf {old_deployment_path}`
- Nullifies `deployment_path` in database records (preserves history)
## Rollback Flow
**Files:**
- `app/Http/Controllers/ServerSiteDeploymentsController.php` - Controller (`rollback()`)
- `app/Packages/Services/Sites/Deployment/SiteDeploymentRollbackJob.php` - Job
- `app/Packages/Services/Sites/Deployment/SiteDeploymentRollbackInstaller.php` - Installer
### When User Clicks "Rollback":
**Step 1: Validation**
```php
// Check if deployment can be rolled back
if (!$deployment->canRollback()) {
return error; // Failed or no deployment_path
}
// Prevent rolling back to current active deployment
if ($site->active_deployment_id === $deployment->id) {
return error;
}
```
**Step 2: Dispatch Rollback Job**
```php
SiteDeploymentRollbackJob::dispatch($server, $site, $targetDeployment);
```
**Step 3: Installer Swaps Symlink**
The rollback installer executes:
```bash
# 1. Verify deployment directory exists (e.g., 31072025-143022)
test -d /home/brokeforge/deployments/{domain}/{target_timestamp}
# 2. ATOMIC SYMLINK SWAP (instant rollback!)
ln -sfn deployments/{domain}/{target_timestamp} /home/brokeforge/{domain}
# 3. Reload PHP-FPM
sudo service php{version}-fpm reload
# 4. Update database
# - site.active_deployment_id = {target_deployment_id}
# - site.last_deployed_at = now()
```
**Rollback is instant** - just a symlink swap! No code deployment needed.
## Database Schema
### `server_sites` Table
Key columns for deployments:
- `active_deployment_id` - Foreign key to currently active `ServerDeployment`
- `last_deployment_sha` - Git commit SHA of last deployment
- `last_deployed_at` - Timestamp of last deployment
### `server_deployments` Table
Key columns:
- `server_site_id` - Foreign key to `ServerSite`
- `status` - Enum: `pending`, `updating`, `success`, `failed`
- `deployment_script` - Script executed during deployment
- `deployment_path` - Full path on remote with timestamp (e.g., `/home/brokeforge/deployments/example.com/31072025-143022`)
- `log_file_path` - Path to deployment log inside deployment directory (e.g., `/home/brokeforge/deployments/example.com/31072025-143022/deployment.log`)
- `commit_sha` - Git commit hash deployed
- `branch` - Git branch deployed from
- `started_at`, `completed_at`, `duration_ms` - Timing info
## Frontend Integration
**File:** `resources/js/pages/servers/site-deployments.tsx`
### Real-Time Updates (Reverb Pattern)
```typescript
// Listen for site updates via Laravel Echo
useEcho()
.private(`servers.sites.${site.id}`)
.listen('ServerSiteUpdated', () => {
router.reload({ only: ['site'] }); // Fetch fresh data
});
```
### Deployment Type
```typescript
type Deployment = {
id: number;
status: 'pending' | 'updating' | 'success' | 'failed';
deployment_path: string | null;
commit_sha: string | null;
branch: string | null;
can_rollback: boolean; // True if status=success AND deployment_path exists
is_active: boolean; // True if this is the active deployment
// ... other fields
};
```
### UI Features
- **"Active" Badge** - Shows which deployment is currently live
- **"Rollback" Button** - Only shown when `can_rollback && !is_active`
- **"View Output" Button** - Streams deployment log from remote
- **Real-time status updates** - No page refresh needed
## Key Design Decisions
### Why Symlinks?
**Atomic Deployments:**
- `ln -sfn` is an atomic operation on Linux
- Site switches from old → new deployment instantly
- No partial state during deployment
**Instant Rollbacks:**
- Just swap symlink back to previous deployment
- No code redeployment needed
- Takes ~1 second
**Zero Downtime:**
- Nginx keeps serving old deployment during new deployment build
- Only switches when new deployment is ready
- Users never see downtime
### Why Fresh Clone Per Deployment?
**Isolation:**
- Each deployment is completely independent
- No shared state between deployments
- Can rollback without worrying about git state
**Simplicity:**
- No complex git worktree management
- No git history conflicts
- Each deployment is "clean slate"
**Trade-off:**
- Uses more disk space (mitigated by pruning old deployments)
- Takes longer than `git pull` (but more reliable)
### Why Shared Directories?
**Persistent Data:**
- `storage/` - User uploads, logs, cache must persist
- `.env` - Environment config must persist
- `vendor/` & `node_modules/` - Saves deployment time, disk space
**Symlink Approach:**
- Each deployment symlinks to shared directories
- All deployments see the same data
- Environment config only managed once
## Common Operations
### Manual Prune Command
```bash
php artisan deployments:prune --keep=14
php artisan deployments:prune --keep=5 --dry-run
php artisan deployments:prune --site-id=123
```
The command:
- Keeps last N successful deployments per site
- Deletes deployment directories from remote
- Preserves database records (nullifies `deployment_path`)
### Check Active Deployment
```php
$site = ServerSite::find(1);
$activeDeployment = $site->activeDeployment; // Relationship
$activePath = $activeDeployment->deployment_path;
```
### List Rollback-Eligible Deployments
```php
$rollbackable = $site->deployments()
->where('status', 'success')
->whereNotNull('deployment_path')
->where('id', '!=', $site->active_deployment_id)
->latest()
->get();
```
## Reference Implementations
**Site Provisioning:** `app/Packages/Services/Sites/ProvisionedSiteInstaller.php`
- Initial site setup with symlink structure
- Creates initial deployment record
**Deployment:** `app/Packages/Services/Sites/Deployment/SiteGitDeploymentInstaller.php`
- Versioned deployments with symlink swap
- Auto-pruning logic
**Rollback:** `app/Packages/Services/Sites/Deployment/SiteDeploymentRollbackInstaller.php`
- Simple symlink swap to previous deployment
**Controller:** `app/Http/Controllers/ServerSiteDeploymentsController.php`
- `deploy()` - Triggers new deployment
- `rollback()` - Triggers rollback
**Frontend:** `resources/js/pages/servers/site-deployments.tsx`
- Real-time deployment status
- Rollback UI
## Troubleshooting
### Deployment Directory Missing
**Issue:** `deployment_path` in database but directory deleted on remote
**Solution:**
- User cannot rollback to this deployment (`can_rollback = false`)
- Prune command will nullify the path
- Create new deployment instead
### Site Symlink Broken
**Issue:** `/home/brokeforge/{domain}` symlink points to non-existent directory
**Solution:**
- Site will fail to load (nginx 404/502)
- Rollback to most recent successful deployment with valid path
- Or redeploy from controller
### Shared Directory Symlink Issues
**Issue:** Deployment directory missing shared symlinks
**Solution:**
- Deployment installer automatically creates these
- If missing, manually create:
```bash
cd /home/brokeforge/deployments/{domain}/{timestamp}
ln -sfn ../shared/storage storage
ln -sfn ../shared/.env .env
ln -sfn ../shared/vendor vendor
ln -sfn ../shared/node_modules node_modules
```
### Too Many Deployments
**Issue:** Disk space filling up
**Solution:**
- Auto-prune runs after each deployment (keeps 14)
- Manual prune: `php artisan deployments:prune --keep=5`
- Check: `du -sh /home/brokeforge/deployments/{domain}/*`
## Testing
When testing deployment features:
1. **Test Initial Site Provisioning:**
- Verify directory structure created
- Verify shared directories exist
- Verify nginx config uses symlink path
- Verify initial deployment record created
2. **Test Deployment Flow:**
- Verify new deployment directory created
- Verify shared symlinks created in deployment
- Verify `current` symlink updated
- Verify `active_deployment_id` updated
- Verify old deployments pruned (if >14)
3. **Test Rollback:**
- Verify symlink swapped to target deployment
- Verify `active_deployment_id` updated
- Verify PHP-FPM reloaded
- Verify cannot rollback to current active
4. **Test Pruning:**
- Create 20 deployments
- Verify only last 14 kept
- Verify `deployment_path` nullified for pruned
- Verify database records preserved
## Best Practices
1. **Always use Reverb Package Lifecycle Pattern** for deployments
2. **Never manually modify `/home/brokeforge/{domain}` symlink** - let installer handle it
3. **Shared directories must be writable** by `brokeforge` user
4. **Keep deployment scripts idempotent** - can be run multiple times safely
5. **Test rollback before relying on it** - verify deployment paths exist
6. **Monitor disk space** - deployments use significant space
7. **Keep deployment retention reasonable** - 14 is good balance
8. **Deployment timestamps are in ddMMYYYY-HHMMSS format** - unique and human-readable