Skip to content

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 ​

bash
# SERVER
sudo apt install -y git
git --version

Set identity (needed if the server ever creates a commit, e.g. a tag or an automated changelog):

bash
# SERVER — as deploy
git config --global user.name "Deploy Bot"
git config --global user.email "deploy@example.com"
git config --global init.defaultBranch main

HTTPS vs SSH repository access ​

HTTPSSSH
URLhttps://github.com/user/repo.gitgit@github.com:user/repo.git
Public repo, read-onlyWorks with no credentialsNeeds a key
Private repoNeeds a Personal Access TokenNeeds an SSH key
Credential storageToken in ~/.git-credentials (plaintext) or a helperKey in ~/.ssh/, mode 600
Token expiryPATs expire — deploys break on a schedule you forgotKeys do not expire
Scope controlFine-grained PATs can be repo-scoped and read-onlyDeploy keys are per-repo, read-only optional
Works through restrictive firewallsYes (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 ​

bash
# 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.

bash
# SERVER
chmod 600 ~/.ssh/github_deploy
cat ~/.ssh/github_deploy.pub

2. 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 ​

bash
# SERVER
nano ~/.ssh/config
sshconfig
Host 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 yes
bash
chmod 600 ~/.ssh/config

4. Test ​

bash
# SERVER
ssh -T git@github.com
Hi 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):

bash
# SERVER
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh-keygen -lf ~/.ssh/known_hosts | grep github    # then verify against the published list

ssh-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:

sshconfig
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 yes
bash
git clone git@github-frontend:myorg/frontend.git
git clone git@github-backend:myorg/backend.git

The 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 ​

bash
# SERVER — as deploy
mkdir -p ~/apps
cd ~/apps
git clone git@github.com:myorg/myapp.git
cd myapp

CLONE 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:

bash
sudo chown -R deploy:deploy ~/apps/myapp

Useful clone options:

bash
git clone --depth 1 --branch production git@github.com:myorg/myapp.git
FlagEffect
--depth 1Shallow clone — only the latest commit. Much faster and smaller.
--branch productionClone a specific branch directly
--single-branchDo 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 deployed

Every 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:

bash
# LOCAL
git checkout main
git checkout -b production
git push -u origin production

Protect it on GitHub: Settings → Branches → Add rule for production → require pull request reviews, require status checks to pass, restrict who can push.

bash
# SERVER
cd ~/apps/myapp
git checkout production
git branch --set-upstream-to=origin/production production

The deployment pull ​

bash
# SERVER
cd ~/apps/myapp
git fetch origin
git checkout production
git reset --hard origin/production

USE 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:

bash
# 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 deployed

git 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 ​

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:

  1. Rotate the secret immediately. New database password, new API key, new JWT secret. This is the only step that actually fixes anything.
  2. Remove it from history with git filter-repo (or BFG) and force-push — this helps against casual discovery but does not undo the exposure.
  3. Notify anyone whose data may be affected.

Do step 1 first. Steps 2 and 3 are cleanup.

Prevent it happening:

bash
# LOCAL — scan for secrets before committing
pip install detect-secrets && detect-secrets scan
# or
brew install gitleaks && gitleaks detect

Enable 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/myapp

What 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:

CauseFix
Cloned with sudo, now running as deploysudo chown -R deploy:deploy /home/deploy/apps/myapp ← the correct fix
CI runs as a different user than the repo ownerAlign the users
Docker volume mount with mismatched UIDSet the container user to match the host UID
Ran a build with sudo oncesudo 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:

bash
sudo chown -R deploy:deploy /home/deploy/apps/myapp

Use 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 ​

bash
# SERVER
nano ~/apps/myapp/deploy.sh
bash
#!/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)"
bash
chmod +x ~/apps/myapp/deploy.sh

set -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:

bash
# 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.

bash
# SERVER — after deploying
git rev-parse HEAD > .deployed-commit
date -u +%FT%TZ >> .deployed-commit

Better, expose it from the application:

ts
// 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:

bash
export GIT_COMMIT=$(git rev-parse --short HEAD)
pm2 reload ecosystem.config.cjs --update-env

Now curl https://api.example.com/health answers "what is running?" from anywhere, without SSH.

Common Git deployment errors ​

ErrorCauseFix
Permission denied (publickey)Deploy key not registered, or wrong key offeredssh -T git@github.com; check ~/.ssh/config has IdentitiesOnly yes
fatal: detected dubious ownershipRepo owned by a different usersudo chown -R deploy:deploy <dir>
error: Your local changes would be overwrittenFiles modified on the servergit reset --hard origin/production (destroys them — correct here)
fatal: refusing to merge unrelated historiesRe-cloned or re-initialised repoDelete and re-clone
fatal: could not read Username for 'https://github.com'HTTPS remote with no credentials in a non-interactive shellSwitch to SSH: git remote set-url origin git@github.com:org/repo.git
Host key verification failedGitHub not in known_hosts (typical in CI)ssh-keyscan github.com >> ~/.ssh/known_hosts
error: cannot open '.git/FETCH_HEAD': Permission deniedMixed root/deploy ownership inside .gitsudo chown -R deploy:deploy .
fatal: unable to access ... Could not resolve hostDNS or outbound network failuredig github.com; check outbound firewall rules
remote: Repository not foundDeploy key attached to the wrong repo, or repo renamedVerify the deploy key in repo settings
Deploy succeeds but code is unchangedWrong branch checked out, or a build step was skippedgit log -1 --oneline, git status -sb, then verify build output timestamps

DIAGNOSING GIT SSH PROBLEMS

bash
# SERVER
GIT_SSH_COMMAND="ssh -v" git fetch origin

This 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:

bash
# SERVER
ls -la ~/apps/myapp
drwxr-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.json

Everything owned by deploy:deploy. .env is 600. Nothing owned by root.

bash
# SERVER — audit
find ~/apps/myapp ! -user deploy 2>/dev/null | head

Any output here means something ran as the wrong user. Fix with chown -R.

Production Checklist — Level 7 ​

  • [ ] Repository cloned as deploy, never with sudo
  • [ ] Access via SSH deploy key, read-only, scoped to this repository
  • [ ] Deploy key added to the repository, not to a personal account
  • [ ] ~/.ssh/config maps the host to the right key with IdentitiesOnly yes
  • [ ] ssh -T git@github.com authenticates successfully
  • [ ] GitHub's host key verified and in known_hosts
  • [ ] .env and .env.* are in .gitignore; .env.example is committed
  • [ ] No secrets anywhere in Git history (verified with gitleaks or similar)
  • [ ] Push protection / secret scanning enabled on the repository
  • [ ] A dedicated production branch exists and is protected
  • [ ] Deploy uses git fetch + git reset --hard, never git pull
  • [ ] Deploy script starts with set -euo pipefail
  • [ ] No git clean -fdx anywhere (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

Next: Level 8 — Environment Variables and Secrets →