Git, CI/CD and zero-downtime deploys
From a commit on your laptop to a safe, reversible release in production.
In 30 seconds
Git records every change to your code and lets a team work in parallel on branches. Continuous integration runs checks (lint, tests, build) on every change, so problems are caught in minutes instead of in production. Continuous delivery then ships the tested build to servers in a way that users do not notice and that you can undo quickly.
Why I use it
- I use Git as the team's shared memory: every change has an author, a reason and a way back.
- I rely on automated checks because people get tired and pipelines do not; a red build is cheaper than a broken checkout page.
- I care about zero-downtime deploys because clients take orders and payments around the clock, so there is no good time for a maintenance page.
- Small, frequent, reversible releases are the single biggest reducer of deployment fear I know.
How it works
01Git fundamentals: commits, branches, merge vs rebase
A commit is a snapshot of the whole project plus a message and a pointer to its parent, so history is a chain of snapshots. A branch is just a movable label pointing at a commit, which is why branches are cheap. When two lines of work need to come together you either merge or rebase. A merge creates a new commit with two parents and keeps history exactly as it happened. A rebase replays your commits on top of the latest main, producing a straight line, but it rewrites those commits with new hashes. The rule: rebase your own local or feature branch to tidy it up, never rebase commits other people have already pulled. Good commits are small and focused, with a message that explains why, not only what.
git switch -c feature/order-export
git add -p # stage hunks, review as you go
git commit -m "Add CSV export for orders"
git fetch origin
git rebase origin/main # update your branch, resolve conflicts
git push --force-with-lease # safe force-push of your own branch
git log --oneline --graph -15
02Branching strategies: trunk-based vs GitFlow
Trunk-based development keeps one main branch that is always releasable. Developers work on short-lived branches, ideally merged within a day or two, and hide unfinished features behind feature flags. It minimises painful merges and pairs naturally with continuous deployment. GitFlow uses long-lived develop and main branches plus feature, release and hotfix branches. It suits products with versioned, scheduled releases, such as mobile apps that pass store review or on-premise software where several versions are supported at once. For most web apps, trunk-based with short pull requests wins. Whatever you choose, protect main: require pull requests, passing checks and at least one review, and forbid force-pushes.
- Trunk-based: short branches, feature flags, deploy from main.
- GitFlow: develop + release branches, good for versioned releases.
- Release tags (v1.8.0) mark exactly what went to production.
03Pull requests and code review
A pull request is a proposal to merge a branch, plus a conversation about it. Its job is to catch bugs, share knowledge and keep the codebase consistent, not to show who is senior. Keep pull requests small, ideally under a few hundred lines, because review quality drops sharply with size. The description should say what changed, why, and how it was tested, with screenshots for UI work. Automation should handle style and formatting so humans can focus on logic, data safety, security, naming and missing tests. Reviewers should be specific and kind, and separate blocking issues from suggestions. CODEOWNERS files route changes to the people who know an area, and required status checks stop anything merging while CI is red.
04Continuous integration: a pipeline that runs on every change
Continuous integration means every push and pull request is built and tested automatically in a clean environment. A typical pipeline installs dependencies from the lockfile, runs the linter and type checker, runs unit and feature tests, and builds the production artifact. Order steps from fastest to slowest so most failures appear within a minute or two. Cache dependency downloads between runs. The pipeline must be the same gate for everyone: if it is red, the change does not merge. Below is a minimal GitHub Actions workflow for a Node or Next.js project; GitLab CI, Bitbucket Pipelines and others use very similar ideas with different syntax. Secrets live in the CI provider's secret store, never in the repository.
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
- run: npm test
- run: npm run build
05Deployment strategies: rolling, blue-green, canary
Continuous delivery means every green build can be deployed with one click; continuous deployment means it is deployed automatically. How you replace the running version matters. A rolling deploy replaces instances one at a time behind a load balancer, so capacity dips slightly but service continues. Blue-green keeps two identical environments: you deploy to the idle one (green), test it, then switch traffic at the load balancer or symlink, and switching back is instant. Canary sends a small share of traffic, say five percent, to the new version, watches error rates and latency, and widens gradually. On a single server, the blue-green idea is usually implemented with release folders and an atomic symlink switch, or with a new container started and health-checked before the proxy is pointed at it.
- Rolling: cheap, gradual, two versions live at once.
- Blue-green: instant switch and instant rollback, needs double capacity briefly.
- Canary: limits blast radius, needs good metrics to judge.
06Zero-downtime deploys for Laravel and Next.js
For Laravel on a VPS, the proven pattern is atomic releases: each deploy goes into a new timestamped folder, shares the .env and storage directory through symlinks, installs dependencies and builds caches there, and only then swaps a current symlink in one atomic step. Reloading PHP-FPM clears OPcache, and queue:restart tells workers to finish their current job and start on the new code. For Next.js, build into the new release folder too, then reload the Node process gracefully (pm2 reload in cluster mode starts new workers before stopping old ones), or start a new container and switch the proxy after its health check passes. Watch out for version skew: browsers with an old page open may request old JavaScript chunks, so keep previous static assets available for a while.
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
RELEASE=$APP/releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 --branch main [email protected]:your_user/app.git "$RELEASE"
cd "$RELEASE"
rm -rf storage && ln -s $APP/shared/storage storage
ln -s $APP/shared/.env .env
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan migrate --force
php artisan optimize
ln -sfn "$RELEASE" $APP/current # atomic switch
sudo systemctl reload php8.3-fpm # match your PHP version
php artisan queue:restart
07Database migrations and rollbacks
Code can be rolled back in seconds; data cannot. During any deploy, old and new code run at the same time for a moment, so every migration must work with both versions. The expand and contract pattern makes this safe. To rename a column: first add the new column and write to both (expand), deploy, backfill old rows in batches, switch reads to the new column, and only in a later release drop the old one (contract). Avoid long locks on big tables; add indexes concurrently where the database supports it. Roll back by redeploying the previous release folder or image tag, which is why you keep the last few. For the database, prefer rolling forward with a fixing migration over running down migrations in production, and always have a fresh backup before risky changes.
- Additive first: new columns nullable or with defaults.
- Destructive changes (drop, rename) only after no code uses the old shape.
- Keep several previous releases on disk for instant rollback.
Use it when
- Every project with more than one developer, or more than one deploy per month.
- Whenever downtime costs money: shops, booking systems, payment flows, SaaS.
- When a client needs an audit trail of what changed, when and why.
- Mobile apps too: pipelines can build, sign and upload store builds consistently.
Skip it when
- Full canary infrastructure for a small brochure site; a simple atomic deploy is enough.
- GitFlow's release branches on a web app that deploys daily; they add ceremony without safety.
- Automatic deploy to production without tests; it only ships bugs faster.
Alternatives
- GitHub Actions
CI/CD built into GitHub, huge marketplace of actions, YAML workflows.
- GitLab CI/CD
Integrated pipelines, environments and container registry in one product; self-hostable.
- Deployer / Envoyer
PHP-focused tools that implement atomic release-folder deploys for Laravel.
- Kamal
Deploys Docker containers to plain servers with zero-downtime switching, no orchestrator needed.
Best practices
- Protect main: pull requests, required green checks and at least one review.
- Keep branches short-lived and pull requests small enough to review in one sitting.
- Build once, deploy the same artifact everywhere; never rebuild for production.
- Make every migration backward compatible with the currently running code.
- Keep the last few releases ready and practise rolling back before you need it.
- Run a health check after each deploy and fail the pipeline if it does not pass.
- Store secrets in the CI secret store or on the server, never in Git.
Common mistakes
- Rebasing and force-pushing a branch that teammates already pulled.
- Running git pull and composer install directly in the live folder, leaving the site half-updated for a minute.
- Dropping or renaming a column in the same release as the code change.
- Forgetting queue:restart, so workers keep running old code for hours.
- Huge pull requests that get approved with a quick look because nobody can really review them.
Deep dive
08How Git stores your work, and why almost nothing is lost
Git is a content-addressed database. Every file version is stored as a blob, every directory as a tree that lists blobs and other trees, and every commit as an object pointing to one root tree, its parent commits, an author and a message. Each object is named by the hash of its content, so identical files are stored once and any tampering changes the hash. Branches and tags are refs: small files holding one commit hash. HEAD points at the ref you are on. Because commits are never edited, only new ones created, a rebase or reset does not destroy the old commits; they simply lose their label. The reflog records every position HEAD and each branch had, usually for about 90 days, so after a bad rebase or reset you can find the old hash and point a branch back at it. Garbage collection removes unreachable objects only after that grace period.
git reflog -15 # where HEAD has been
git branch rescue HEAD@{3} # recover the state from 3 moves ago
git reset --hard ORIG_HEAD # undo the last rebase or merge
git fsck --lost-found # find dangling commits and blobs
git cat-file -p HEAD # see a commit object: tree, parent, author
09Securing the pipeline itself
A CI system holds the keys to production, so attackers target it. Four controls matter most. First, least privilege for the job token: declare permissions explicitly at the top of each workflow, read-only by default. Second, avoid long-lived cloud keys entirely: GitHub Actions, GitLab and others can issue a short-lived OIDC token that AWS, Google Cloud or Azure exchange for temporary credentials, restricted to one repository and branch. Third, pin third-party actions to a full commit SHA, not a tag, because tags can be moved by whoever controls that repository. Fourth, be careful with workflows triggered by pull requests from forks: pull_request_target runs with the base repository's secrets, so it must never check out and execute untrusted code. Production deploys should go through a protected environment with required reviewers, so a compromised branch cannot deploy on its own.
permissions:
contents: read
id-token: write # allow OIDC token for cloud login
jobs:
deploy:
environment: production # required reviewers + env-scoped secrets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/deploy-web
aws-region: eu-central-1
10Build once, promote by digest, and serialise deploys
A mature pipeline builds the artifact exactly once, on the merge to main, tags it with the commit SHA and pushes it to a registry. Staging and production then deploy that same artifact, identified by its immutable digest, so what you tested is byte-for-byte what you ship. Rebuilding for production reintroduces risk: a dependency or base image may have changed in between. Two deploys running at the same time can also leave a server half on each version, so serialise them with a concurrency group, and do not cancel a deploy that is already running, because cancelling halfway is worse than finishing. Record each deployment with its commit, time and author, so the question what is running in production right now has a one-line answer. Rollback is then just redeploying the previous digest.
concurrency:
group: deploy-production
cancel-in-progress: false # queue, never kill a running deploy
# build stage (once):
# docker build -t registry.example.com/web:$GITHUB_SHA .
# docker push registry.example.com/web:$GITHUB_SHA
# deploy stages reuse the same tag/digest, no rebuild
11Schema changes on big tables without locking the site
Expand and contract keeps code and schema compatible, but each step can still lock a table. In PostgreSQL, most ALTER TABLE forms take an ACCESS EXCLUSIVE lock. Even a fast change has to wait for any long-running transaction to finish, and while it waits, every new query on that table queues behind it, so a two-millisecond migration can freeze the site for minutes. Protect yourself with a short lock_timeout and retry. Build indexes with CREATE INDEX CONCURRENTLY, which avoids blocking writes but cannot run inside a transaction, so frameworks that wrap migrations in transactions need it switched off for that migration (in Laravel, set the migration's withinTransaction property to false). Adding a NOT NULL constraint on a huge table is safer as a CHECK constraint added NOT VALID and validated later. On MySQL, prefer ALGORITHM=INSTANT where supported, or tools such as gh-ost for large rebuilds.
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN export_status text; -- metadata-only, fast
CREATE INDEX CONCURRENTLY idx_orders_export_status
ON orders (export_status);
ALTER TABLE orders ADD CONSTRAINT orders_export_status_nn
CHECK (export_status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_export_status_nn;
Real-world scenarios
A secret was committed and pushed
Problem
A developer notices that a commit pushed an hour ago contains a production API key inside a config file. The repository is private, but several people and the CI system have already pulled it.
How to solve it
Rotate first, clean second. Revoke the key at the provider and issue a new one immediately, because anything pushed must be treated as leaked, and check the provider's logs for unexpected use. Then remove the file from the code and move the value into the secret store. Rewriting history with git filter-repo is optional for a private repository, and it forces everyone to re-clone, so do it only if policy requires. Finally prevent a repeat: enable secret scanning and push protection on the host, add a pre-commit hook such as gitleaks, and add the config file pattern to .gitignore.
A bug appeared somewhere in the last 200 commits
Problem
Order totals are wrong in a rare case. The last release that was definitely correct was three weeks ago, and nobody can tell which of roughly 200 commits introduced the problem.
How to solve it
Use git bisect, which binary-searches history, so 200 commits need only about eight checks. Write a small script or test that reproduces the bug and exits non-zero when it fails. Then run git bisect start, git bisect bad HEAD, git bisect good v1.7.0, and git bisect run ./check-totals.sh. Git checks out commits, runs the script and reports the first bad commit. Read that diff, write a permanent regression test from your script, fix the bug and finish with git bisect reset. Bisect works best when every commit builds, which is one more reason for small, self-contained commits.
The pipeline takes 25 minutes and people stop waiting for it
Problem
Developers merge before CI finishes or re-run failing jobs until they pass. Main is red twice a week and nobody trusts the checks any more.
How to solve it
Measure each step first; usually two or three steps dominate. Cache dependency downloads keyed on the lockfile, and cache build output where the tool supports it. Split independent work into parallel jobs: lint and type check, unit tests, and the build can run side by side, and large test suites can be sharded across several runners. Skip irrelevant jobs with path filters in a monorepo. Quarantine flaky tests into a separate, non-blocking job with an owner and a deadline, instead of allowing reruns. Then make the checks required in branch protection, or add a merge queue, so nothing reaches main without a green run. A target of under ten minutes is realistic for most web apps.
Cheat sheet
- Recover a lost commit or branch
git reflog then git branch rescue <hash>- Undo a merged pull request safely
git revert -m 1 <merge-commit-hash>- Find the commit that broke something
git bisect start && git bisect bad && git bisect good v1.7.0 && git bisect run ./test.sh- Find when a string was added or removed
git log -S "calculateTotal" --oneline- Fix an earlier commit before review
git commit --fixup <hash> && git rebase -i --autosquash origin/main- Work on a hotfix without stashing
git worktree add ../app-hotfix origin/main- Force-push only if nobody else pushed
git push --force-with-lease- Serialise deploys in GitHub Actions
concurrency: { group: deploy-production, cancel-in-progress: false }- Let CI log in to a cloud without keys
permissions: { id-token: write, contents: read }- Postgres index without blocking writes
CREATE INDEX CONCURRENTLY idx_name ON orders (column);
In my real projects
- Roughly 7,100 commits across 19 production repositories, about half of all commits in the team's repos.
- The React Native apps have a documented release process, with Remote Config force-update as a safety net when an old version must be retired.
- The POS SaaS offline branch hub uses versioned SQLite migrations pinned by checksum, so a changed migration cannot silently run.
- Changes go through a planner, implementer and reviewer agent workflow with post-edit type-check hooks before anything is merged.
Questions & answers
Q1What is the difference between merge and rebase, and when do you use each?
Merge joins two branches with a merge commit that has two parents; history stays exactly as it happened, including when branches diverged. Rebase takes your commits and replays them on top of another base, producing a linear history, but it creates new commits with new hashes. I rebase my own feature branch onto the latest main before opening or updating a pull request, because it keeps the diff clean and conflicts are resolved by the person who knows the change. I never rebase commits that others have pulled. For integrating into main, a merge or squash-merge is fine; the team should simply pick one convention.
Q2Trunk-based development or GitFlow?
For a web application that deploys often, trunk-based. Short-lived branches merged within a day or two avoid big merge conflicts, keep main always releasable and fit continuous deployment. Unfinished work is hidden behind feature flags rather than living on a branch for weeks. GitFlow still makes sense when releases are versioned and scheduled, for example mobile apps going through store review, desktop software, or products where several versions are supported in parallel. Even then I keep feature branches short. The strategy matters less than the discipline: small changes, protected main, mandatory checks and reviews.
Q3What is the difference between continuous integration, delivery and deployment?
Continuous integration means every change is merged frequently and automatically built and tested, so integration problems show up within minutes. Continuous delivery goes further: every change that passes the pipeline produces a release candidate that can be deployed to production at any time, usually with a manual approval button. Continuous deployment removes that button, so every green change goes to production automatically. Deployment needs strong tests, good monitoring and fast rollback, because no human checks each release. Many teams run continuous deployment to staging and continuous delivery to production, which is a sensible middle ground.
Q4How do you run database migrations without downtime?
I assume old and new code will run at the same time for a while, so every migration must be compatible with both. That means expand and contract. Expand: add new tables or nullable columns, deploy code that writes to both shapes, backfill existing rows in batches. Contract: once nothing reads the old shape, remove it in a later release. I avoid operations that lock large tables for long, add indexes concurrently where possible, and run heavy data changes as background jobs rather than inside the deploy. Before risky migrations I confirm a fresh backup exists, and I prefer fixing forward over down migrations in production.
Q5Blue-green or canary deployment?
Blue-green runs two identical environments; you deploy to the idle one, verify it, then switch all traffic at once. Rollback is just switching back, which is very fast and simple to reason about, but you briefly need double capacity and every user moves together. Canary routes a small percentage of traffic to the new version, compares error rate and latency with the old one, and increases the share step by step. It limits the blast radius of a bad release, but it needs good metrics, traffic splitting and automation to be worth it. Small teams usually get most of the value from blue-green style atomic switches.
Q6A deploy broke production. What do you do?
First restore service, then investigate. If the deploy is the likely cause, roll back immediately to the previous release or image tag; that is why I keep several releases ready. If a migration already changed data, rolling back code must still work with the new schema, which expand and contract guarantees. If rollback is impossible, ship a small fix forward. While doing this, I communicate status to stakeholders. Afterwards I write a short blameless postmortem: timeline, root cause, why tests and monitoring did not catch it, and one or two concrete actions such as a new test, alert or deploy check.
Senior-level questions
S1How would you design CI/CD for a monorepo with several apps?
The goal is that each change runs only the work it affects, while shared code changes still test everything that depends on them. A build tool that understands the dependency graph, such as Turborepo, Nx or Bazel, can compute affected projects and cache task results locally and remotely, so unchanged packages are not rebuilt. CI then runs lint, tests and builds per affected app in parallel jobs, and each app keeps its own deploy pipeline, version and rollback. Path filters are a simpler start but miss transitive dependencies. Trade-offs: a monorepo makes cross-cutting changes atomic and keeps tooling consistent, but needs this investment in caching and ownership rules, typically CODEOWNERS per directory. Polyrepos keep pipelines simple but turn shared-library updates into a release-and-bump chore across many repositories.
S2Feature flags or long-lived branches for big features?
Flags, in most cases. A long-lived branch drifts away from main, so the final merge is large, risky and hard to review, and the feature is tested against an old version of everything else. With flags, code is merged in small pieces behind a switch that is off in production, so integration happens continuously, and you can enable the feature for staff, then one percent of users, then everyone, and turn it off instantly without a deploy. The costs are real: both code paths must work and be tested, flags add conditional complexity, and stale flags become technical debt. Mitigate that with a naming convention, an owner and an expiry date for each flag, and a cleanup ticket created the day the flag reaches one hundred percent.
S3A migration is not reversible. How do you make the release safe anyway?
Accept that rollback means rolling back code, not data, and design so that is always enough. Split the change so the risky, destructive part comes last and alone: first an additive migration that old code ignores, then new code that uses it, then, a release or two later, the destructive step once metrics show nothing reads the old shape. Before that final step, take and verify a fresh backup, or confirm point-in-time recovery covers it, and rehearse the migration on a production-sized copy to measure duration and locks. Run large data rewrites as idempotent background jobs in batches, so they can pause and resume. If something still goes wrong, the plan is to fix forward with a new migration, and the backup is for disaster, not for routine rollback.
Glossary
- Ref
- A named pointer to a commit, such as a branch or tag. Moving a branch just rewrites its ref.
- HEAD
- The ref for what you currently have checked out; usually it points to a branch, which points to a commit.
- Reflog
- A local log of every position HEAD and branches had, used to recover commits after resets or rebases.
- Fast-forward
- A merge where the target branch simply moves forward to the new commit because history has not diverged.
- Squash merge
- Combining all commits of a pull request into one commit on main, giving a short, readable history.
- Merge queue
- A system that tests each pull request on top of the latest main plus queued changes before merging it.
- OIDC token
- A short-lived signed identity token CI issues to a job, which a cloud trades for temporary credentials.
- Artifact
- The built output of a pipeline, such as a container image or archive, deployed unchanged to every environment.
- Feature flag
- A runtime switch that turns code paths on or off without a deploy, separating release from deployment.
- Version skew
- Old and new versions of code, or client and server, running at the same time and needing to cooperate.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.