Level 7 — Git and Repository Deployment
Getting your code onto the server. The mechanics are simple; the failure modes (permissions, ownership, credentials) are where the time goes.
Git in one paragraph
Git is a distributed version control system. Every clone contains the full history. A commit is an immutable snapshot identified by a SHA hash; a branch is a movable pointer to a commit; a remote is another copy of the repository (on GitHub or GitLab) that you push to and pull from. For deployment you care about exactly one thing: getting a specific commit onto the server reliably and repeatably.
Install Git
# SERVER
sudo apt install -y git
git --versionSet identity (needed if the server ever creates a commit, e.g. a tag or an automated changelog):
# SERVER — as deploy
git config --global user.name "Deploy Bot"
git config --global user.email "deploy@example.com"
git config --global init.defaultBranch mainHTTPS vs SSH repository access
| HTTPS | SSH | |
|---|---|---|
| URL | https://github.com/user/repo.git | git@github.com:user/repo.git |
| Public repo, read-only | Works with no credentials | Needs a key |
| Private repo | Needs a Personal Access Token | Needs an SSH key |
| Credential storage | Token in ~/.git-credentials (plaintext) or a helper | Key in ~/.ssh/, mode 600 |
| Token expiry | PATs expire — deploys break on a schedule you forgot | Keys do not expire |
| Scope control | Fine-grained PATs can be repo-scoped and read-only | Deploy keys are per-repo, read-only optional |
| Works through restrictive firewalls | Yes (port 443) | Needs port 22 outbound (or ssh.github.com:443) |
USE AN SSH DEPLOY KEY ON THE SERVER
It does not expire, it is scoped to one repository, it can be read-only, and the private key never leaves the server. A PAT sitting in a plaintext file on disk is both broader in scope and more likely to leak.
Setting up a deploy key
A deploy key is an SSH key pair granting access to exactly one repository.
1. Generate the key on the server
# SERVER — as deploy
ssh-keygen -t ed25519 -C "deploy@prod-app-01" -f ~/.ssh/github_deploy -N ""-N "" sets an empty passphrase. Necessary here — no human is present to type one during an automated deploy. The key is protected by file permissions and by never leaving the server.
# SERVER
chmod 600 ~/.ssh/github_deploy
cat ~/.ssh/github_deploy.pub2. Add the public key to the repository
GitHub: Repository → Settings → Deploy keys → Add deploy key. Paste the .pub contents. Leave "Allow write access" unchecked.
GitLab: Repository → Settings → Repository → Deploy keys → Expand → Add key. Grant read-only.
DEPLOY KEY, NOT ACCOUNT KEY
If you add this key to your personal account (Settings → SSH keys) instead of the repository's deploy keys, the server gains access to every repository you can reach. A compromised server then means a compromised source-code estate. Repository-scoped deploy keys limit the damage to one repo, read-only.
READ-ONLY IS ALMOST ALWAYS RIGHT
The server pulls; it does not push. Granting write access means an attacker who compromises the server can push malicious code to your repository — which CI then deploys everywhere. Leave write access off.
3. Tell SSH to use this key for GitHub
# SERVER
nano ~/.ssh/configHost github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_deploy
IdentitiesOnly yes
# If you also use GitLab
Host gitlab.com
HostName gitlab.com
User git
IdentityFile ~/.ssh/gitlab_deploy
IdentitiesOnly yeschmod 600 ~/.ssh/config4. Test
# SERVER
ssh -T git@github.comHi user/project! You've successfully authenticated, but GitHub does not provide shell access.That message means success — GitHub never gives you a shell.
First connection asks you to verify GitHub's host key. Compare the fingerprint against GitHub's published SSH key fingerprints before typing yes. To pre-seed it non-interactively (useful in provisioning scripts):
# SERVER
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh-keygen -lf ~/.ssh/known_hosts | grep github # then verify against the published listssh-keyscan TRUSTS WHATEVER ANSWERS
It records whatever key the host presents, with no verification — a man-in-the-middle at that moment is recorded permanently. Verify the fingerprint afterwards, at least once.
Multiple repositories on one server
Give each repo its own key and a distinct Host alias:
Host github-frontend
HostName github.com
User git
IdentityFile ~/.ssh/deploy_frontend
IdentitiesOnly yes
Host github-backend
HostName github.com
User git
IdentityFile ~/.ssh/deploy_backend
IdentitiesOnly yesgit clone git@github-frontend:myorg/frontend.git
git clone git@github-backend:myorg/backend.gitThe alias in the clone URL selects the key. GitHub requires a unique key per deploy key, so this pattern is mandatory once you have more than one repo.
Cloning the repository
# SERVER — as deploy
mkdir -p ~/apps
cd ~/apps
git clone git@github.com:myorg/myapp.git
cd myappCLONE AS deploy, NOT WITH sudo
sudo git clone creates a root-owned directory. Every subsequent git pull, pnpm install, and build as deploy then fails with permission errors, and you will be tempted to "fix" it with sudo forever after.
If you already did it:
sudo chown -R deploy:deploy ~/apps/myappUseful clone options:
git clone --depth 1 --branch production git@github.com:myorg/myapp.git| Flag | Effect |
|---|---|
--depth 1 | Shallow clone — only the latest commit. Much faster and smaller. |
--branch production | Clone a specific branch directly |
--single-branch | Do not fetch other branches |
SHALLOW CLONES LIMIT WHAT YOU CAN DO LATER
With --depth 1 you cannot git log, git diff against older commits, or check out a previous commit to roll back — all of which you want during an incident. You can deepen later (git fetch --unshallow), but that needs network access at the worst possible moment.
Use a full clone unless the repository is genuinely large. Disk is cheaper than a blocked rollback.
Branches and production branches
Two workable strategies:
Strategy A — deploy from main with tags
main ──●──●──●──●──● (always deployable)
↑ ↑
v1.0 v1.1 ← tags mark what was deployedEvery merge to main triggers a deploy. Simple, matches trunk-based development, and works well with a good CI test suite.
Strategy B — a dedicated production branch
main ──●──●──●──●──● (integration)
╲ ╲
production ────────●─────● (deployed)main is where work lands; merging main → production is a deliberate act that triggers deployment. You get a manual gate and a clear record of what is live.
RECOMMENDATION
Use Strategy B when you deploy to a single production server and want an explicit "go live" step. The production branch becomes an unambiguous answer to "what is running right now?", and rolling back is git reset --hard <previous-commit> on that branch.
Use Strategy A with a staging environment when you have solid automated tests and deploy often.
Either way: the server must never have local commits. It is a read-only mirror of a branch.
Setting up Strategy B:
# LOCAL
git checkout main
git checkout -b production
git push -u origin productionProtect it on GitHub: Settings → Branches → Add rule for production → require pull request reviews, require status checks to pass, restrict who can push.
# SERVER
cd ~/apps/myapp
git checkout production
git branch --set-upstream-to=origin/production productionThe deployment pull
# SERVER
cd ~/apps/myapp
git fetch origin
git checkout production
git reset --hard origin/productionUSE fetch + reset --hard, NOT pull
git pull is fetch + merge. If anything on the server differs from the remote — a stray edit, a file mode change, a generated file that got committed — the merge can conflict and stop your deployment halfway, leaving a half-updated working tree.
git reset --hard origin/production forces the working tree to exactly match the remote, unconditionally. On a deployment target that is precisely what you want: the server should be a mirror, never a place where work happens.
This is destructive — it discards any local modifications. That is the point, but be certain you are running it in the app directory and not somewhere you have unsaved work.
A complete, safe deployment fetch:
# SERVER
set -euo pipefail
cd ~/apps/myapp
git fetch origin --prune
git checkout production
git reset --hard origin/production
git clean -fd # remove untracked files (see below)
git log -1 --oneline # record what we just deployedgit clean -fd DELETES UNTRACKED FILES
This includes anything not in Git and not in .gitignore. It does not delete files matched by .gitignore (like .env and node_modules) unless you add -x.
Never use git clean -fdx on a deployment server — it would delete your .env file and your entire node_modules. Plain -fd is the safe form, and even then, verify with git clean -nd (dry run) the first time.
.gitignore
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
.output/
.nuxt/
build/
# Environment — NEVER commit these
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
# OS / editor
.DS_Store
.vscode/
.idea/
# Prisma
prisma/*.db
prisma/migrations/dev/
# Test / coverage
coverage/
.nyc_output/Note !.env.example — the negation keeps a committed template of required variables while excluding the real ones.
IF YOU EVER COMMIT A SECRET, IT IS COMPROMISED
Adding .env to .gitignore afterwards does nothing — the value is in the history, in every clone, and quite possibly in a GitHub search index. Bots scan public pushes for AWS keys and database URLs within seconds.
The response, in order:
- Rotate the secret immediately. New database password, new API key, new JWT secret. This is the only step that actually fixes anything.
- Remove it from history with
git filter-repo(or BFG) and force-push — this helps against casual discovery but does not undo the exposure. - Notify anyone whose data may be affected.
Do step 1 first. Steps 2 and 3 are cleanup.
Prevent it happening:
# LOCAL — scan for secrets before committing
pip install detect-secrets && detect-secrets scan
# or
brew install gitleaks && gitleaks detectEnable GitHub Secret Scanning + Push Protection (free on public repos, available on private with Advanced Security). It blocks the push rather than alerting after the fact.
safe.directory — the dubious ownership error
fatal: detected dubious ownership in repository at '/home/deploy/apps/myapp'
To add an exception for this directory, call:
git config --global --add safe.directory /home/deploy/apps/myappWhat it means: Git 2.35.2+ refuses to operate on a repository owned by a different user than the one running Git. The reason is a real vulnerability class: .git/config can specify commands (core.fsmonitor, hooks) that Git executes. If an attacker can write to a repo you then run git status in, they get code execution as you.
Why it happens on a deployment server:
| Cause | Fix |
|---|---|
Cloned with sudo, now running as deploy | sudo chown -R deploy:deploy /home/deploy/apps/myapp ← the correct fix |
| CI runs as a different user than the repo owner | Align the users |
| Docker volume mount with mismatched UID | Set the container user to match the host UID |
Ran a build with sudo once | sudo chown -R deploy:deploy . |
FIX THE OWNERSHIP, NOT THE WARNING
The suggested git config --global --add safe.directory ... silences the check without fixing the underlying problem. The mismatched ownership will keep causing EACCES failures in pnpm install and build steps anyway.
Correct the ownership:
sudo chown -R deploy:deploy /home/deploy/apps/myappUse safe.directory only when the ownership mismatch is genuinely intentional and unavoidable (some containerised CI runners). Never use git config --global --add safe.directory '*' — that disables the protection everywhere.
A complete deployment script
# SERVER
nano ~/apps/myapp/deploy.sh#!/usr/bin/env bash
#
# Deploy script for myapp. Run as the `deploy` user.
# ~/apps/myapp/deploy.sh
#
set -euo pipefail
# -e exit on any error
# -u error on undefined variable (prevents rm -rf "$UNSET"/*)
# -o pipefail a failing command in a pipe fails the whole pipeline
APP_DIR="/home/deploy/apps/myapp"
BRANCH="production"
# Load NVM — this script may run from a non-interactive shell
export NVM_DIR="$HOME/.nvm"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
cd "$APP_DIR"
echo "==> Current commit: $(git rev-parse --short HEAD)"
echo "==> Fetching latest code"
git fetch origin --prune
git checkout "$BRANCH"
git reset --hard "origin/$BRANCH"
git clean -fd
echo "==> New commit: $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)"
echo "==> Installing dependencies"
pnpm install --frozen-lockfile
echo "==> Running database migrations"
pnpm prisma migrate deploy
echo "==> Building"
pnpm build
echo "==> Reloading application"
pm2 reload ecosystem.config.cjs --update-env
echo "==> Waiting for health check"
sleep 3
curl -fsS http://127.0.0.1:3001/health > /dev/null && echo "✅ API healthy" || {
echo "❌ Health check failed"
pm2 logs --lines 50 --nostream
exit 1
}
echo "==> Deployed $(git rev-parse --short HEAD) at $(date -u +%FT%TZ)"chmod +x ~/apps/myapp/deploy.shset -euo pipefail IS MANDATORY IN DEPLOY SCRIPTS
Without -e, a failed pnpm build does not stop the script — it proceeds to pm2 reload, which restarts your app with a broken or missing build. You get a working-looking deploy that serves errors.
Without -u, an unset variable expands to an empty string, and rm -rf "$APP_DIR"/* becomes rm -rf /*.
Without -o pipefail, pnpm build | tee build.log returns tee's exit status (always 0), hiding the build failure entirely.
THIS SCRIPT HAS DOWNTIME AND NO ROLLBACK
It updates the working tree in place, so during pnpm build the running app's files are changing underneath it. It also has no way back if the new version is broken.
That is acceptable for a small project, and it is where most people start. Level 20 shows the release-directory pattern that makes deploys atomic and rollback instant.
Run it:
# SERVER
~/apps/myapp/deploy.sh
# LOCAL — or trigger remotely
ssh deploy@203.0.113.10 "~/apps/myapp/deploy.sh"Recording what is deployed
Knowing exactly which commit is live is essential during an incident.
# SERVER — after deploying
git rev-parse HEAD > .deployed-commit
date -u +%FT%TZ >> .deployed-commitBetter, expose it from the application:
// NestJS — health controller
@Get('health')
health() {
return {
status: 'ok',
commit: process.env.GIT_COMMIT ?? 'unknown',
startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
};
}Set GIT_COMMIT at deploy time:
export GIT_COMMIT=$(git rev-parse --short HEAD)
pm2 reload ecosystem.config.cjs --update-envNow curl https://api.example.com/health answers "what is running?" from anywhere, without SSH.
Common Git deployment errors
| Error | Cause | Fix |
|---|---|---|
Permission denied (publickey) | Deploy key not registered, or wrong key offered | ssh -T git@github.com; check ~/.ssh/config has IdentitiesOnly yes |
fatal: detected dubious ownership | Repo owned by a different user | sudo chown -R deploy:deploy <dir> |
error: Your local changes would be overwritten | Files modified on the server | git reset --hard origin/production (destroys them — correct here) |
fatal: refusing to merge unrelated histories | Re-cloned or re-initialised repo | Delete and re-clone |
fatal: could not read Username for 'https://github.com' | HTTPS remote with no credentials in a non-interactive shell | Switch to SSH: git remote set-url origin git@github.com:org/repo.git |
Host key verification failed | GitHub not in known_hosts (typical in CI) | ssh-keyscan github.com >> ~/.ssh/known_hosts |
error: cannot open '.git/FETCH_HEAD': Permission denied | Mixed root/deploy ownership inside .git | sudo chown -R deploy:deploy . |
fatal: unable to access ... Could not resolve host | DNS or outbound network failure | dig github.com; check outbound firewall rules |
remote: Repository not found | Deploy key attached to the wrong repo, or repo renamed | Verify the deploy key in repo settings |
| Deploy succeeds but code is unchanged | Wrong branch checked out, or a build step was skipped | git log -1 --oneline, git status -sb, then verify build output timestamps |
DIAGNOSING GIT SSH PROBLEMS
# SERVER
GIT_SSH_COMMAND="ssh -v" git fetch originThis shows the full SSH negotiation — which key was offered, whether it was accepted. Same diagnostic value as ssh -v (Level 1).
Repository ownership and deploy user permissions
Correct end state:
# SERVER
ls -la ~/apps/myappdrwxr-xr-x 12 deploy deploy 4096 Aug 11 12:00 .
drwxr-xr-x 8 deploy deploy 4096 Aug 11 11:55 .git
-rw------- 1 deploy deploy 512 Aug 11 12:00 .env
-rw-r--r-- 1 deploy deploy 2048 Aug 11 12:00 package.jsonEverything owned by deploy:deploy. .env is 600. Nothing owned by root.
# SERVER — audit
find ~/apps/myapp ! -user deploy 2>/dev/null | headAny output here means something ran as the wrong user. Fix with chown -R.
Production Checklist — Level 7
- [ ] Repository cloned as
deploy, never withsudo - [ ] Access via SSH deploy key, read-only, scoped to this repository
- [ ] Deploy key added to the repository, not to a personal account
- [ ]
~/.ssh/configmaps the host to the right key withIdentitiesOnly yes - [ ]
ssh -T git@github.comauthenticates successfully - [ ] GitHub's host key verified and in
known_hosts - [ ]
.envand.env.*are in.gitignore;.env.exampleis committed - [ ] No secrets anywhere in Git history (verified with gitleaks or similar)
- [ ] Push protection / secret scanning enabled on the repository
- [ ] A dedicated
productionbranch exists and is protected - [ ] Deploy uses
git fetch+git reset --hard, nevergit pull - [ ] Deploy script starts with
set -euo pipefail - [ ] No
git clean -fdxanywhere (it would delete.env) - [ ] Everything under the app directory is owned by
deploy:deploy - [ ] The deployed commit is recorded and exposed via a health endpoint