Git hooks are scripts that Git runs automatically at specific points, such as before a commit is recorded or after a push is received. A post-receive hook on your server is the simplest possible deployment pipeline: you run git push production main and the new version goes live seconds later, with no CI service involved. In this tutorial you will set up push-to-deploy for a static website served by Nginx on Ubuntu 24.04, using timestamped releases and an atomic symlink switch so that you can roll back instantly. You will also add client-side hooks that stop broken commits before they leave your machine.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user with
sudoprivileges. This guide usesyour_userfor that account. - Nginx installed on the server (
sudo apt install nginx) and port 80 open in UFW (sudo ufw allow 'Nginx HTTP'). - SSH key access from your workstation to
your_user@your_server_ip. - Git installed on both your workstation and the server (
sudo apt install git). - Optionally, a domain name pointing to the server. The examples use
your_domain.
Step 1 - Creating the deployment directories
Each deployment will be extracted into its own directory under releases/, and a symlink called current will point to the live one. Switching the symlink is a single atomic rename, so visitors never see a half-copied site, and rolling back is just pointing the symlink at an older release.
On the server, create the directory structure and give your user ownership of it:
sudo mkdir -p /var/www/your_domain/releases
sudo chown -R your_user:your_user /var/www/your_domain
Nginx only needs read access, which the default permissions already give it.
Step 2 - Creating a bare repository on the server
A bare repository contains only Git's internal data, with no working copy. It is the correct type of repository to push to, because there are no checked-out files for a push to conflict with.
As your_user on the server, create it in your home directory:
git init --bare --initial-branch=main ~/site.git
Initialized empty Git repository in /home/your_user/site.git/
Hooks live in the hooks/ directory of this repository. Git creates it with example files ending in .sample, which are inactive until you remove the suffix and make them executable.
Step 3 - Writing the post-receive hook
Git runs post-receive once after a push has been fully accepted. It receives one line per updated branch on standard input, in the form OLD_COMMIT NEW_COMMIT REF_NAME. Anything the hook prints is shown to the person who pushed, prefixed with remote:.
Create the hook:
nano ~/site.git/hooks/post-receive
#!/usr/bin/env bash
# Deploy pushes to the main branch as a new release and switch to it atomically.
set -euo pipefail
DEPLOY_BRANCH="main"
APP_ROOT="/var/www/your_domain"
KEEP_RELEASES=5
while read -r oldrev newrev ref; do
if [[ "$ref" != "refs/heads/${DEPLOY_BRANCH}" ]]; then
echo "Received ${ref}; only ${DEPLOY_BRANCH} is deployed."
continue
fi
if [[ "$newrev" =~ ^0+$ ]]; then
echo "Branch ${DEPLOY_BRANCH} was deleted; nothing to deploy."
continue
fi
release="${APP_ROOT}/releases/$(date +%Y%m%d%H%M%S)-${newrev:0:7}"
echo "Deploying ${newrev:0:7} to ${release}"
mkdir -p "$release"
git archive "$newrev" | tar -x -C "$release"
# Atomically point "current" at the new release
ln -sfn "$release" "${APP_ROOT}/current.tmp"
mv -T "${APP_ROOT}/current.tmp" "${APP_ROOT}/current"
echo "Now serving ${newrev:0:7} (previous: ${oldrev:0:7})"
# Keep only the newest KEEP_RELEASES releases
find "${APP_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
| sort -r \
| tail -n +"$((KEEP_RELEASES + 1))" \
| while read -r old; do
rm -rf -- "${APP_ROOT}/releases/${old}"
echo "Removed old release ${old}"
done
done
How the important parts work:
git archive "$newrev" | tar -xexports exactly the files in the pushed commit, without the.gitdirectory, so repository history is never exposed on the web server.ln -sfncreates a temporary symlink andmv -Trenames it overcurrent. A rename is atomic on Linux, so Nginx always sees either the old or the new release.- Release names start with a timestamp, so sorting them by name also sorts them by age. The cleanup never touches the release that
currentpoints to, because it is always the newest.
Make the hook executable. Git silently skips hooks that are not:
chmod +x ~/site.git/hooks/post-receive
Step 4 - Pointing Nginx at the current release
Create a server block whose document root is the current symlink:
sudo nano /etc/nginx/sites-available/your_domain
server {
listen 80;
listen [::]:80;
server_name your_domain;
root /var/www/your_domain/current;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Enable the site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Nginx will return 404 until the first deployment creates current.
Step 5 - Pushing your first deployment
Switch to your workstation. Create a small site in a new repository, or use an existing project:
mkdir my-site && cd my-site
git init --initial-branch=main
echo '<h1>Deployed with Git hooks</h1>' > index.html
git add index.html
git commit -m "Add home page"
Add the server as a remote called production. The path is relative to your_user's home directory:
git remote add production your_user@your_server_ip:site.git
Push to deploy:
git push production main
Enumerating objects: 3, done.
Writing objects: 100% (3/3), 245 bytes | 245.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Deploying 4f2c9a1 to /var/www/your_domain/releases/20260925113012-4f2c9a1
remote: Now serving 4f2c9a1 (previous: 0000000)
To your_server_ip:site.git
* [new branch] main -> main
Verify the site is live:
curl http://your_domain
<h1>Deployed with Git hooks</h1>
Change index.html, commit and push again. A new release directory is created and current moves to it. On the server, you can see all releases and where current points:
ls -l /var/www/your_domain /var/www/your_domain/releases
Step 6 - Rolling back to a previous release
Because every release is kept on disk, a rollback does not need Git at all. On the server, list the releases, newest first:
ls -1r /var/www/your_domain/releases
20260925114501-9b81d03
20260925113012-4f2c9a1
Point current back at the previous release using the same atomic switch as the hook:
ln -sfn /var/www/your_domain/releases/20260925113012-4f2c9a1 /var/www/your_domain/current.tmp
mv -T /var/www/your_domain/current.tmp /var/www/your_domain/current
Confirm the old version is being served with curl http://your_domain. The next git push deploys normally again. For a permanent fix, revert the bad commit with git revert and push, so that your repository and your server agree.
Step 7 - Sharing client-side hooks with your team
Server-side hooks deploy code; client-side hooks catch mistakes before a commit is even created. Hooks in .git/hooks are not versioned, so store them in a tracked .githooks directory and point Git at it with core.hooksPath.
In your project on the workstation, create the directory:
mkdir .githooks
Create a pre-commit hook that blocks leftover merge conflict markers, whitespace errors and private keys:
nano .githooks/pre-commit
#!/usr/bin/env bash
# Reject commits with conflict markers, whitespace errors or private keys.
set -euo pipefail
# Detects conflict markers and trailing whitespace in staged changes
if ! git diff --cached --check; then
echo "pre-commit: fix the problems listed above before committing." >&2
exit 1
fi
if git grep --cached -l -e 'BEGIN [A-Z ]*PRIVATE KEY' > /dev/null; then
echo "pre-commit: a private key is staged:" >&2
git grep --cached -l -e 'BEGIN [A-Z ]*PRIVATE KEY' >&2
exit 1
fi
Create a commit-msg hook that enforces the Conventional Commits format on the first line. Git passes the path of the message file as the first argument:
nano .githooks/commit-msg
#!/usr/bin/env bash
# Require "type(scope): description" on the first line of the commit message.
set -euo pipefail
first_line="$(head -n 1 "$1")"
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,72}$'
if [[ "$first_line" =~ ^(Merge|Revert) ]]; then
exit 0
fi
if [[ ! "$first_line" =~ $pattern ]]; then
echo "commit-msg: expected 'type(scope): description', for example 'fix(nginx): correct root path'." >&2
echo "commit-msg: got '${first_line}'" >&2
exit 1
fi
Make both hooks executable, tell Git to use the directory and commit it:
chmod +x .githooks/pre-commit .githooks/commit-msg
git config core.hooksPath .githooks
git add .githooks
git commit -m "chore: add shared git hooks"
Each teammate runs git config core.hooksPath .githooks once after cloning, since Git never enables hooks automatically for security reasons.
Test the commit-msg hook with a message that does not follow the format:
echo "<p>test</p>" >> index.html
git commit -am "updated stuff"
commit-msg: expected 'type(scope): description', for example 'fix(nginx): correct root path'.
commit-msg: got 'updated stuff'
The commit is rejected. Run it again with a valid message, such as git commit -am "feat: add test paragraph", and it succeeds.
Noteanyone can skip client-side hooks with
git commit --no-verify. Treat them as a convenience for your team, and enforce rules that must never be broken on the server side (for example in apre-receivehook) or in your CI pipeline.
Troubleshooting
- The push succeeds but nothing is deployed and no
remote:lines appear: the hook is not executable. Runchmod +x ~/site.git/hooks/post-receiveon the server. remote: mkdir: cannot create directory ... Permission denied: the user you push as does not own/var/www/your_domain. Fix it withsudo chown -R your_user:your_user /var/www/your_domain.remote: Received refs/heads/master; only main is deployed.: your local branch has a different name. Push withgit push production master:mainor rename the branch.- Nginx returns 403 or 404 after a deploy: check that
currentexists withls -l /var/www/your_domainand that the release contains anindex.htmlat its top level. - A hook fails with
/usr/bin/env: 'bash\r': No such file or directory: the hook was saved with Windows line endings. Convert it withsed -i 's/\r$//' HOOK_FILE.
Conclusion
You built a push-to-deploy workflow with a bare repository and a post-receive hook that publishes each push as an immutable release, switches to it atomically and keeps recent releases for instant rollback. You also shared pre-commit and commit-msg hooks with your team through core.hooksPath.
To go further, add a build step to the hook (for example npm ci && npm run build inside the release directory) for sites that need one, restart an application service with sudo systemctl reload through a narrowly scoped sudoers rule, or enable HTTPS for your domain with Certbot.
