Deploying with Git
Git-based deployment means your server gets new code straight from a Git repository (a place where your code and its history live, like GitHub or a folder on the server). Instead of copying files by hand with tools like scp, you run a small script that pulls the latest commit and restarts your app. It is the simplest way to ship code to a Linux server, and it is the perfect stepping stone before you learn full CI/CD pipelines (automated systems that build, test, and deploy code for you). This page shows you two patterns that actually work on a fresh Ubuntu server, and exactly where they stop being good enough.
Why deploy with Git at all
When you first put an app on a server, you need a repeatable way to get new code onto it. Manually uploading files is slow and error-prone, you forget which files changed, and you cannot easily undo a bad change. Git already tracks every change, so “deploy” can become “pull the latest commit and restart the service”. That is fast, it gives you a clear history of what is running, and rolling back is just checking out an older commit.
When to use this: small projects, one or two servers, a solo developer or tiny team, internal tools, and learning. When NOT to use this: anything where you need automated tests before deploy, multiple servers in sync, build steps that should not run on production, or a team where many people deploy. At that point, move to CI/CD.
Pattern 1: A pull-based deploy script
This is the most common and easiest approach. Your code already lives in a normal Git clone on the server (a working copy of the repository). A deploy is just: pull the newest code, install dependencies, and restart the app.
First, on the server, clone your repository into a deploy directory. We will use a Node.js app as the example, but the idea is identical for Python, Java, or anything else.
sudo mkdir -p /var/www/myapp
sudo chown $USER:$USER /var/www/myapp
git clone https://github.com/yourname/myapp.git /var/www/myapp
cd /var/www/myapp
npm ci
Use a deploy-only SSH key or a fine-grained read-only access token for cloning private repos. Never paste a personal password or a full-access token onto a server. If the server is compromised, a read-only key limits the damage.
Now create the deploy script. Save it as /var/www/myapp/deploy.sh:
#!/usr/bin/env bash
# Pull-based deploy script for myapp
set -euo pipefail # stop on any error, undefined var, or failed pipe
APP_DIR="/var/www/myapp"
BRANCH="main"
SERVICE="myapp" # the systemd service name
cd "$APP_DIR"
echo "==> Fetching latest code"
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH" # match remote exactly, discard local edits
echo "==> Installing dependencies"
npm ci --omit=dev # clean, reproducible install of production deps only
echo "==> Building"
npm run build
echo "==> Restarting service"
sudo systemctl restart "$SERVICE"
echo "==> Deploy finished: $(git rev-parse --short HEAD)"
Make it executable and run it:
chmod +x /var/www/myapp/deploy.sh
/var/www/myapp/deploy.sh
Output:
==> Fetching latest code
From github.com:yourname/myapp
* branch main -> FETCH_HEAD
HEAD is now at 9f2c1ab Add health check endpoint
==> Installing dependencies
added 214 packages in 6s
==> Building
> [email protected] build
> vite build
✓ built in 3.41s
==> Restarting service
==> Deploy finished: 9f2c1ab
A few important notes. We use git reset --hard origin/main instead of git pull on purpose. git pull can fail or create merge conflicts if anything on the server changed (for example a log file or an edited config). reset --hard forces the working copy to match the remote exactly, so deploys are predictable. The trade-off: never edit files directly on the server, because those edits will be wiped on the next deploy. Keep all config in environment variables instead.
To allow the sudo systemctl restart line to run without a password prompt, add a narrow sudoers rule:
sudo visudo -f /etc/sudoers.d/myapp-deploy
deployuser ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp
This grants permission for exactly one command and nothing else, which is far safer than giving the deploy user full sudo.
Pattern 2: A post-receive hook on a bare repo
The second pattern turns the server itself into a Git remote you can push to. When you run git push production main from your laptop, the server automatically checks out the new code and restarts. This feels magical and removes the manual SSH step.
It works using a bare repository (a Git repo with no working files, just the history) plus a post-receive hook (a script Git runs automatically after it receives a push).
On the server, create the bare repo and the live directory:
sudo mkdir -p /var/repo/myapp.git /var/www/myapp
sudo chown -R $USER:$USER /var/repo/myapp.git /var/www/myapp
git init --bare /var/repo/myapp.git
Now create the hook at /var/repo/myapp.git/hooks/post-receive:
#!/usr/bin/env bash
set -euo pipefail
TARGET="/var/www/myapp"
GIT_DIR="/var/repo/myapp.git"
BRANCH="main"
while read -r oldrev newrev ref; do
if [[ "$ref" = "refs/heads/$BRANCH" ]]; then
echo "==> Deploying $BRANCH to $TARGET"
# Check out the pushed code into the live directory
git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f "$BRANCH"
cd "$TARGET"
npm ci --omit=dev
npm run build
sudo systemctl restart myapp
echo "==> Done"
else
echo "==> Ref $ref pushed; not $BRANCH, ignoring"
fi
done
Make it executable:
chmod +x /var/repo/myapp.git/hooks/post-receive
On your laptop, add the server as a remote and push:
git remote add production deployuser@your-server-ip:/var/repo/myapp.git
git push production main
Output:
Enumerating objects: 12, done.
Writing objects: 100% (7/7), 812 bytes | 812.00 KiB/s, done.
remote: ==> Deploying main to /var/www/myapp
remote: added 214 packages in 6s
remote: ✓ built in 3.39s
remote: ==> Done
To your-server-ip:/var/repo/myapp.git
1c3a9de..9f2c1ab main -> main
Now every git push production main deploys. The while read loop is important: Git passes the old commit, new commit, and the ref (branch) on standard input, so the hook only acts when the right branch is pushed.
Git deploy vs CI/CD — when to use which
| Concern | Git deploy (pull script or hook) | CI/CD pipeline |
|---|---|---|
| Setup effort | Minutes, one script | Hours, a config file plus a runner |
| Automated tests before deploy | No | Yes |
| Build happens on | The production server | A separate build machine |
| Multiple servers | Manual, one at a time | Built in |
| Team of many developers | Risky, no gatekeeping | Designed for it |
| Rollback | git checkout <old-sha> and restart | One click / re-run old build |
| Audit log of deploys | Just Git history | Full run logs and approvals |
The key weakness of Git deploys: the build and install run on the production server. If npm ci fails halfway, your live app can break. There are no automated tests gating the deploy, so a bug goes straight to users. And keeping ten servers in sync by hand does not scale. These are exactly the problems CI/CD solves, which is why Git deploy is a stepping stone, not a destination.
Build on the server only for small apps. A heavy build can use all the CPU and memory and slow down or crash the live app at the same moment users are hitting it. If your build is large, build a release artifact elsewhere and ship that instead.
Best Practices
- Use
git reset --hard origin/<branch>instead ofgit pullso deploys are deterministic and never hit merge conflicts. - Never edit files directly on the server; keep all configuration in environment variables so deploys cannot wipe your changes.
- Use a read-only deploy key or token for cloning, and a narrow
NOPASSWDsudoers rule for only the restart command. - Always
set -euo pipefailat the top of deploy scripts so a failed step stops the deploy instead of restarting a broken app. - Tag or log the deployed commit SHA (with
git rev-parse --short HEAD) so you always know exactly what is running. - Outgrow it on purpose: the day you need tests, multiple servers, or a team, move to a real CI/CD pipeline.