--- title: "Push to Deploy with Git Hooks on Ubuntu 24.04" description: "Set up push-to-deploy with a bare Git repository and a post-receive hook on Ubuntu 24.04, with atomic releases, instant rollback and shared pre-commit hooks." category: "Automation Tools" language: en source: https://cubepath.com/docs/automation-tools/git-hooks-for-automatic-deployment updated: 2026-09-25 --- # Push to Deploy with Git Hooks on Ubuntu 24.04 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 `sudo` privileges. This guide uses `your_user` for 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: ```bash 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: ```bash git init --bare --initial-branch=main ~/site.git ``` ```output 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: ```bash nano ~/site.git/hooks/post-receive ``` ```bash #!/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 -x` exports exactly the files in the pushed commit, without the `.git` directory, so repository history is never exposed on the web server. - `ln -sfn` creates a temporary symlink and `mv -T` renames it over `current`. 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 `current` points to, because it is always the newest. Make the hook executable. Git silently skips hooks that are not: ```bash 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: ```bash sudo nano /etc/nginx/sites-available/your_domain ``` ```nginx 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: ```bash sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` ```output 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: ```bash mkdir my-site && cd my-site git init --initial-branch=main echo '

Deployed with Git hooks

' > 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: ```bash git remote add production your_user@your_server_ip:site.git ``` Push to deploy: ```bash git push production main ``` ```output 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: ```bash curl http://your_domain ``` ```output

Deployed with Git hooks

``` 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: ```bash 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: ```bash ls -1r /var/www/your_domain/releases ``` ```output 20260925114501-9b81d03 20260925113012-4f2c9a1 ``` Point `current` back at the previous release using the same atomic switch as the hook: ```bash 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: ```bash mkdir .githooks ``` Create a `pre-commit` hook that blocks leftover merge conflict markers, whitespace errors and private keys: ```bash nano .githooks/pre-commit ``` ```bash #!/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: ```bash nano .githooks/commit-msg ``` ```bash #!/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: ```bash 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: ```bash echo "

test

" >> index.html git commit -am "updated stuff" ``` ```output 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. > Note: anyone 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 a `pre-receive` hook) or in your CI pipeline. ## Troubleshooting - **The push succeeds but nothing is deployed and no `remote:` lines appear**: the hook is not executable. Run `chmod +x ~/site.git/hooks/post-receive` on the server. - **`remote: mkdir: cannot create directory ... Permission denied`**: the user you push as does not own `/var/www/your_domain`. Fix it with `sudo 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 with `git push production master:main` or rename the branch. - **Nginx returns 403 or 404 after a deploy**: check that `current` exists with `ls -l /var/www/your_domain` and that the release contains an `index.html` at 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 with `sed -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.