Deployment concepts
The model behind a safe deploy
A deploy is the scariest button most app owners ever press, because the naive version of it is genuinely dangerous: you overwrite the running code while people are using it. This guide explains the model that makes a deploy boring instead — atomic releases switched by a symlink, with user data and config held safely off to the side.
Read this once to understand why the deploy works. When you’re ready to actually run it, the playbooks carry the exact commands.
The problem: overwriting live code is dangerous
Section titled “The problem: overwriting live code is dangerous”Imagine the simplest possible deploy: git pull straight into the live folder the web server is reading from. For the few seconds the new files are landing, the site is half-old, half-new. A request that arrives mid-pull hits a broken mix. Worse, if the new code is wrong, you have nothing clean to fall back to — you’ve already destroyed the working version.
Zero-downtime deployment fixes this with one idea: never overwrite the running app. Build the new version completely off to the side, then flip a single pointer.
The structure: releases, shared, and a current pointer
Section titled “The structure: releases, shared, and a current pointer”Every deploy creates a brand-new, fully-built copy of the app in its own timestamped folder. A symlink called current points at whichever release is live. A separate shared/ folder holds the things that must survive across deploys — your .env config and everyone’s uploaded files.
graph TD Current["current<br/>(symlink — the live app)"] Current --> R3["releases/20251130_1423<br/>(release 3 — newest)"] R2["releases/20251130_1201<br/>(release 2)"] R1["releases/20251129_1655<br/>(release 1)"] Shared["shared/<br/>.env · storage · uploads<br/>(persists across releases)"] R3 -.->|symlink| Shared R2 -.->|symlink| Shared R1 -.->|symlink| SharedThe web server only ever follows current. Because each release is a complete, independent copy, switching versions is just re-pointing one symlink — an operation that takes a fraction of a second and can never leave the site in a half-built state.
This is also why rollback is instant: the previous release still exists on disk, fully built. Going back is the same symlink flip in reverse — about 2 seconds, no rebuild. Deployer keeps the last few releases (commonly keep_releases: 5) precisely so you always have something clean to flip back to.
Symlinks: why user data doesn’t vanish on deploy
Section titled “Symlinks: why user data doesn’t vanish on deploy”A new release is a fresh copy of your code. If uploaded files and config lived inside that copy, every deploy would wipe them. The fix is symlinks — pointers that make a path inside the release resolve to the shared folder instead.
There are three kinds, and knowing which is which is the whole skill:
| Symlink type | Example | Where it points | Who creates it |
|---|---|---|---|
| Framework-required | current/.env, current/storage | shared/ | Deployer’s deploy:shared (automatic) |
| Web-accessible shared content | current/public/uploads, current/public/storage | shared/ (user uploads) | A custom deploy task |
| Release-specific assets | current/public/packages | the release’s own code (NOT shared) | A custom deploy task |
The trap to internalize: shared = user data; not-shared = code. User uploads must persist across releases, so they live in shared/ and get symlinked in. Source code (a packages/ directory of module assets, for example) should change with every release — so it stays inside the release and is never shared. Putting source code in shared_dirs freezes it across deploys; that’s a bug.
Standard modern Laravel keeps uploads under storage/app/public, so a single shared_dirs entry of storage covers it. Many CodeCanyon apps use a legacy pattern with a custom uploads/ directory at the project root — those need that directory added to shared_dirs too, and the deploy task creates the matching web symlink automatically.
The deploy flow: build off to the side, then flip
Section titled “The deploy flow: build off to the side, then flip”A Deployer run is a sequence of hooks. The mental model is: do all the slow, risky work in the new release folder while the old one keeps serving traffic, and only switch the symlink at the very end once the new release is proven good.
graph LR Prepare["Prepare<br/>(new release folder)"] Prepare --> Code["Pull code<br/>+ clear dev paths"] Code --> Vendors["Install deps<br/>(composer)"] Vendors --> Sharedstep["Link shared<br/>(.env · storage)"] Sharedstep --> Symlink["Flip current →<br/>new release"] Symlink --> Checks["Security +<br/>health checks"] Checks --> Done["Success<br/>(or auto-rollback)"]Two safety mechanisms ride along the whole way:
- Deployment lock. Deployer locks at the start and unlocks at the end, so two deploys can never run at once and corrupt each other. If a run dies and leaves a stale lock, you clear it manually.
- Failure cleanup. If any step fails before the symlink flip, the live site never changed — the old release is still serving. The failed half-built release folder gets removed, the failure is logged, and you fix and retry. No downtime, because the flip never happened.
clear_paths is the one piece that surprises people: certain files exist in git but get deleted from the deployed release — local admin tooling, CI workflows, tests, the deploy script itself. They belong in the repo but have no business on the live server. This list must stay in sync with whatever server-sync workflow captures changes back into git, or you risk silently deleting config.
CI/CD safety nets: the things worth adding
Section titled “CI/CD safety nets: the things worth adding”Deployer gives you atomic releases, instant rollback, and locking out of the box. The genuinely useful additions are about visibility and recovery — knowing what shipped and being able to undo it confidently.
| Capability | Built in? | What it buys you |
|---|---|---|
| Instant rollback | ✅ Deployer | ~2-second recovery from a bad deploy |
| Release history | ✅ Deployer | See every available rollback point |
| Deployment lock | ✅ Deployer | No two deploys collide |
| Deploy summary + audit log | ➕ custom task | A human-readable record of what shipped, when, by whom |
| Failed-deploy cleanup | ➕ custom task | Broken releases removed automatically; old one keeps serving |
| Health check endpoint | ➕ custom route | One URL confirms DB, storage, and cache are alive post-deploy |
| Slack / Discord / Telegram alerts | ✅ contrib recipe | The team hears about success and failure |
A health-check route (e.g. /health-check) is the cheapest high-value add: it pings the database, confirms storage is writable, exercises the cache, and reports maintenance state — returning 200 when healthy and 503 otherwise. After a deploy, one request tells you the new release is genuinely up, not just “the files copied.”
Maintenance mode: when zero-downtime isn’t enough
Section titled “Maintenance mode: when zero-downtime isn’t enough”The symlink flip gives you zero downtime for ordinary code changes. But some deploys carry a risk the flip can’t hide — chiefly database migrations that change schema while users are mid-action. For those, you deliberately take the app offline for the risky window.
Laravel’s php artisan down / php artisan up are the built-in switches; a deploy task wraps them and a custom 503.blade.php makes the offline page look intentional instead of broken. A --secret bypass URL lets you keep testing while everyone else sees the maintenance page.
graph TD Q{"Does this deploy<br/>risk breaking live requests?"} Q -->|"No — code-only,<br/>instant or no migration"| Fast["Skip maintenance mode<br/>(true zero downtime)"] Q -->|"Yes — risky/slow migration,<br/>schema change, single server"| Maint["Enable maintenance mode<br/>→ migrate → disable"]The rule of thumb: default to zero-downtime; reach for maintenance mode only for risky or slow schema changes on a single server. On a load-balanced setup you’d use a blue-green strategy instead, and API consumers that expect 200 are a reason to avoid the 503 window.