The Complete Guide to Git Deployment: From post-receive Hooks to Modern Delivery Architectures

In the early days of web development and server operations, FTP / SFTP was the ubiquitous tool for shipping code to production servers.

However, manually dragging and dropping files over FTP introduced critical engineering liabilities:

  1. Human Error and Omission: Developers frequently forgot which specific files were modified. Overlooking a single configuration file or updated stylesheet could break the entire production environment.
  2. Lack of Traceability and Atomicity: If a network drop occurred midway through an upload, the server ended up in a corrupt, half-baked state with mixed legacy and fresh files. Recovering or rolling back was excruciating.
  3. Collaboration Clashes: Team members routinely overwrote each other’s changes, giving rise to elusive, hard-to-diagnose production bugs.

As the distributed version control system Git gained universal adoption, an intuitive and revolutionary vision emerged: “What if every time we ran git push in our terminal, the production server automatically updated and deployed itself?”

This gave birth to the classic Git Push Deployment architecture.


1. Core Principles: Git Hooks and Bare Repositories

Git is far more than a historical commit database; it is an event-driven automation framework. Git provides customizable lifecycle triggers known as Git Hooks.

Among these, the server-side post-receive hook is ideally suited for deployment automation: it executes automatically on the remote server after a push completes and all commit objects are written.

flowchart LR
    subgraph LocalDev ["Local Development Environment"]
        WorkTree["Working Directory"] -- "git push prod-server main" --> SSH["Encrypted SSH Channel"]
    end

    subgraph RemoteServer ["Production Server"]
        direction TB
        BareRepo["Git Bare Repository<br/>/var/repo/site.git"]
        Hook["post-receive Hook Script"]
        WebRoot["Live Web Root Directory<br/>/var/www/site"]

        BareRepo -->|"Trigger on Receive"| Hook
        Hook -->|"git --work-tree checkout"| WebRoot
    end

    SSH --> BareRepo

In naive early implementations, developers sometimes initialized Git directly inside /var/www/site and set receive.denyCurrentBranch ignore. However, this posed a grave security risk: if web servers (Nginx / Apache) inadvertently served the .git directory, malicious actors could download your complete source code and history!

The industry-standard, secure architecture physically decouples the Bare Repository (storage of Git objects without a working directory) from the Live Web Root:

1. Server-Side Setup

Assume the following directory structure:

  • Bare Repository Path: /var/repo/project.git
  • Web Root Directory: /var/www/production.com
# 1. Create the bare repository
sudo mkdir -p /var/repo/project.git
cd /var/repo/project.git
sudo git init --bare

# 2. Create the target web directory
sudo mkdir -p /var/www/production.com

# 3. Ensure your deploy user (e.g. deploy or www-data) owns both paths
sudo chown -R deploy:deploy /var/repo/project.git
sudo chown -R deploy:deploy /var/www/production.com

2. Crafting the post-receive Hook

Navigate to the hooks directory within the bare repository and create post-receive:

cat << 'EOF' > /var/repo/project.git/hooks/post-receive
#!/usr/bin/env bash
set -e

# Define target paths
TARGET="/var/www/production.com"
GIT_DIR="/var/repo/project.git"
BRANCH="main"

echo "==== Git Push received: Starting deployment ===="

# Read push metadata from stdin
while read oldrev newrev ref
do
    # Only deploy when pushing to target branch (e.g. main)
    if [[ "$ref" =~ .*/$BRANCH$ ]]; then
        echo "Checking out branch $BRANCH into $TARGET ..."
        git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f "$BRANCH"
        
        # Execute post-deployment tasks (optional)
        cd "$TARGET"
        # composer install --no-dev --optimize-autoloader
        # npm install --production && npm run build
        # sudo systemctl reload php-fpm
        
        echo "==== Deployment successfully finished! ===="
    else
        echo "Branch $ref is not $BRANCH. Skipping deployment."
    fi
done
EOF

# Grant execution permissions (Crucial step!)
chmod +x /var/repo/project.git/hooks/post-receive

3. Local Configuration and One-Command Shipping

Inside your local workstation’s project directory, register the remote server:

# Add a remote named prod
git remote add prod [email protected]:/var/repo/project.git

# From now on, shipping code is a single command away!
git push prod main

Your terminal stream mirrors the server’s post-receive execution output in real time, showing checkout status and daemon reloads.


3. A Decade of Evolution: From Git Push to Zero-Downtime Delivery

From basic post-receive scripts in 2013 to modern containerized cloud delivery, software deployment has progressed through four distinct paradigms:

flowchart TD
    G1["Generation 1: Manual FTP / SFTP<br/>• Manual drag-and-drop file transfers<br/>• Zero version tracking, highly error-prone"]
    G2["Generation 2: Git Push Hooks<br/>• Automated checkout via post-receive<br/>• Version-controlled, but overwrites live tree directly"]
    G3["Generation 3: Zero-Downtime Symlink Releases<br/>• Capistrano / Deployer pattern<br/>• releases/ timestamp dirs + current symlink switch"]
    G4["Generation 4: Modern CI/CD & GitOps<br/>• GitHub Actions / Docker / Kubernetes<br/>• Immutable build artifacts & automated rollback"]

    G1 --> G2 --> G3 --> G4

1. Why Direct git checkout -f Falls Short in Complex Apps

As modern applications expanded to incorporate dependency installations (composer install, npm install), compilation pipelines (Vite, Webpack), and database migrations, directly overwriting files in the active web root introduced two critical issues:

  • Transient Service Downtime: Visitors accessing the site during a 30-second npm run build would encounter partially compiled scripts, leading to blank screens.
  • Irrecoverable Build Failures: If a build crashed due to out-of-memory errors halfway through, the live directory was already tainted, making instant recovery difficult.

To eliminate downtime, tools like Capistrano and PHP’s Deployer introduced the atomic symlink release pattern (an architecture also employed by this blog’s deployment engine):

/var/www/site/
├── current -> /var/www/site/releases/20261104120000  (Atomic symlink to latest release)
├── releases/
│   ├── 20261104110000/                              (Previous release)
│   └── 20261104120000/                              (New release)
└── shared/                                          (Persistent cross-release storage)
    ├── .env
    └── storage/

The deployment runner creates a fresh directory under releases/<timestamp>, checks out source files, runs compilation tasks, and runs smoke tests. Once verified, it executes an atomic symlink swap:

ln -sfn /var/www/site/releases/20261104120000 /var/www/site/current

If an unexpected bug surfaces, repointing the current symlink back to the previous timestamp directory executes a lossless rollback in under 0.1 seconds.


4. Five Production Best Practices for Git Deployments

  1. Strictly Block Public .git Access: If utilizing a non-bare repository structure, block web server access to .git:
    location ~ /\.git {
        deny all;
        return 404;
    }
  2. Never Run Deployments as root: Provision a dedicated unprivileged user (deploy), authenticate using SSH keypairs (~/.ssh/authorized_keys), and disable password-based SSH access.
  3. Keep Secrets (.env) Out of Git: Environment secrets, API credentials, and database credentials should reside in a persistent location on the host (shared/.env), linked dynamically during releases.
  4. Prune Stale Releases: To prevent disk exhaustion, clean up old release folders automatically, keeping only the most recent 3 to 5 builds (ls -dt releases/* | tail -n +6 | xargs rm -rf).
  5. Modern Convergence: Integrating with CI/CD: In modern production environments, git push usually targets GitHub or GitLab, where automated test suites and linters run in ephemeral runners before rsyncing finished artifacts to your servers.

Conclusion

From the initial excitement of replacing FTP with git push in 2013 to today’s sophisticated CI/CD pipelines, Git revolutionized how software is delivered to users.

While tooling abstractions continue to evolve—from bare virtual servers to Docker containers and Serverless runtimes—the enduring principles remain immutable:

Treat version control as the single source of truth, automate delivery through auditable scripts, and protect production stability with zero-downtime mechanisms.