--- 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 '
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.