Security and extras: secrets, Dependabot, LFS, submodules and Pages
Keep secrets out and respond fast when one leaks, keep dependencies patched with Dependabot, store large files with LFS, embed repositories with submodules, publish with Pages.
By the end of this lesson you'll be able to
0 of 5 completeSecrets: keep them out, and respond fast
A secret in Git is in every clone, every fork and every backup, forever. Bots scan public pushes within minutes. Defence comes in layers, from your laptop to GitHub:
- Never in code. Read secrets from environment variables or a secrets manager.
.envfiles go in.gitignore(the Commits lesson), with a committed.env.examplethat lists the names and no values. - Block on your laptop with a pre-commit hook such as gitleaks (the Hooks lesson).
- Push protection on GitHub rejects a push containing a recognised secret (cloud keys, tokens and more):
remote: error: GH013: Repository rule violations found for refs/heads/main. remote: - GITHUB PUSH PROTECTION remote: Push cannot contain secrets
- Secret scanning finds secrets already in the repository and alerts you. For many token types GitHub also notifies the provider, which may revoke the key automatically.
Turn on 3 and 4 under Settings → Advanced Security (free for public repositories; for private ones it depends on your plan). Turn on push protection for yourself too (Settings → Code security in your personal settings), so it covers every repository you push to.
When a secret leaks anyway
| Order | Do | Why |
|---|---|---|
| 1 | Rotate it: create a new key, deploy it, revoke the old one | the leaked one becomes worthless |
| 2 | Check the provider's logs for use of the old key | was it already abused? |
| 3 | Remove it from the code and commit | stop it spreading to new clones |
| 4 | If required, purge it from history with git filter-repo and force-push | cleans the repository, not the copies |
Skipping step 1 because you cleaned history is the classic mistake: forks, clones and caches still have it.
Dependabot: patched dependencies every week
Most code you ship is other people's: libraries, base images, GitHub Actions. Dependabot watches them:
- Dependabot alerts tell you when a dependency you use has a known vulnerability.
- Security updates open a pull request that upgrades it to a fixed version.
- Version updates open regular pull requests to keep everything current, so upgrades stay small.
Version updates are configured in .github/dependabot.yml. This is this site's own file:
version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: weekly day: monday timezone: Asia/Kolkata open-pull-requests-limit: 5 cooldown: default-days: 7 # don't propose a release until it's a week old groups: minor-and-patch: update-types: [minor, patch] # one PR for all small updates; majors one at a time commit-message: prefix: chore(deps) - package-ecosystem: github-actions directory: / schedule: interval: weekly groups: actions: patterns: ['*']
The cooldown is a supply-chain defence: packages that are hijacked and republished with malware are usually caught and removed within days, so waiting a week before upgrading avoids them. Security updates ignore the cooldown, so real fixes still arrive immediately. Groups keep the number of pull requests manageable, and CI on each one tells you whether the upgrade is safe to merge.
Signed commits
The author on a commit is just text. Anyone can run git config user.email rajeev@example.com and commit as
me; Git doesn't check. On a platform with many contributors, that's how a malicious change can be dressed up
as coming from a trusted maintainer. A signed commit carries a cryptographic signature made with a
private key only the author has, so anyone can check it really came from that key and wasn't changed since.
Git can sign with SSH keys (simplest: you probably already have one) or GPG keys. With SSH:
$ git config --global gpg.format ssh $ git config --global user.signingkey ~/.ssh/id_ed25519.pub $ git config --global commit.gpgsign true # sign every commit; tags too with tag.gpgsign
git config --global gpg.format sshgitversion controlconfigread or set settings--globalfor your user, in every repo
To verify signatures locally, Git needs to know which key belongs to whom: an allowed signers file, one line per person, with their email and public key:
$ echo "rajeev@example.com namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers $ git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
echo "rajeev@example.com namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signersechoprint text>>append output to a file
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signersgitversion controlconfigread or set settings--globalfor your user, in every repo
Now every commit is signed, and you can check one:
$ git log --show-signature -1 commit b2f51bfe80d8ab977ecea0178ebd12d3f4dcd879 Good "git" signature for rajeev@example.com with ED25519 key SHA256:99JvyxZRV/H7ig/fIBIOySHCScyJW2Cp++oQgtM21D4 Author: Rajeev Kumar <rajeev@example.com> Date: Fri Oct 16 09:00:00 2026 +0530 Add README $ git verify-commit HEAD; echo "exit $?" Good "git" signature for rajeev@example.com with ED25519 key SHA256:99JvyxZRV/H7ig/fIBIOySHCScyJW2Cp++oQgtM21D4 exit 0
git log --show-signature -1gitversion controlloghistory
For a quick audit, %G? prints one letter per commit: G good, N no signature, B bad, U good but
from a key you haven't marked as trusted:
$ git log --format='%h %G? %s' -2 16dab07 N Unsigned commit b2f51bf G Add README $ git verify-commit 16dab07; echo "exit $?" exit 1
git log --format='%h %G? %s' -2gitversion controlloghistory
On GitHub, add the same public key under Settings → SSH and GPG keys as a Signing key (separate from
the authentication key, even if it's the same file). Your commits then show a Verified badge, and a
ruleset can Require signed commits on main. Commits made in the GitHub web interface are signed by
GitHub itself.
GPG works the same way with gpg.format openpgp (the default) and user.signingkey set to your GPG key
ID. It's older and has features SSH signing lacks, such as key expiry and a web of trust, but needs more
setup. Unless your company already uses GPG, start with SSH.
.gitattributes: rules per file
.gitattributes tells Git how to treat files by pattern: how to store line endings, whether a file is text
or binary, how to diff and merge it, and what to leave out of an export. It's committed, so the rules apply
to everyone, unlike personal settings such as core.autocrlf. Git LFS (below) is one user of it.
# .gitattributes * text=auto eol=lf *.bat text eol=crlf *.png binary package-lock.json -diff linguist-generated docs/** linguist-documentation tests/ export-ignore .gitattributes export-ignore
Line endings. Windows editors write CRLF, Linux and macOS LF. Mixed endings make every line look changed
and break shell scripts (bash\r: not found). text=auto eol=lf stores LF in the repository and checks out
LF everywhere; .bat files keep CRLF because Windows needs it. Git warns as it normalises a file that was
saved with CRLF:
$ git add . warning: in the working copy of 'build.bat', LF will be replaced by CRLF the next time Git touches it warning: in the working copy of 'deploy.sh', CRLF will be replaced by LF the next time Git touches it $ git ls-files --eol i/lf w/lf attr/text=auto eol=lf .gitattributes i/lf w/lf attr/text eol=crlf build.bat i/lf w/crlf attr/text=auto eol=lf deploy.sh i/lf w/lf attr/text=auto eol=lf docs/index.md i/crlf w/crlf attr/-text logo.png i/lf w/lf attr/text=auto eol=lf package-lock.json i/lf w/lf attr/text=auto eol=lf tests/test_cart.py
git add .gitversion controladdstage changes.this folder
i/ is what's stored in the index, w/ what's in your working folder: deploy.sh was saved with CRLF but is
stored as LF. Add .gitattributes early in a project; adding it later means one commit that renormalises
everything (git add --renormalize .).
Diff and merge drivers. -diff hides the content of noisy generated files in diffs:
$ git diff package-lock.json diff --git a/package-lock.json b/package-lock.json index ea4d790..53e90bd 100644 Binary files a/package-lock.json and b/package-lock.json differ
git diff package-lock.jsongitversion control
merge=union keeps both sides' lines instead of conflicting, handy for a changelog where two branches
each add an entry:
$ git merge feature/coupons # without the attribute CONFLICT (content): Merge conflict in CHANGELOG.md $ git merge --abort $ echo 'CHANGELOG.md merge=union' >> .gitattributes && git commit -qam "Union-merge the changelog" $ git merge -q --no-edit feature/coupons $ cat CHANGELOG.md # Changelog - 1.4.0 first release - Fix empty cart crash - Add coupon codes
git merge feature/couponsgitversion control
echo 'CHANGELOG.md merge=union' >> .gitattributes && git commit -qam "Union-merge the changelog"echoprint text>>append output to a file&&run the next command only if this one workedcommitsave staged changes
cat CHANGELOG.mdcatprint a file
Only use union where line order doesn't matter: it never conflicts, so it will also happily keep two
contradicting lines.
Export and GitHub. export-ignore leaves paths out of git archive (and GitHub's source downloads),
so release tarballs don't ship your tests. The linguist-* attributes are read only by GitHub: generated
files are collapsed in pull request diffs, and documentation and vendored code don't count in the
repository's language bar.
$ git archive --format=tar HEAD | tar -t build.bat deploy.sh docs/ docs/index.md logo.png package-lock.json
git archive --format=tar HEAD | tar -tgitversion control|pipe: send output to the next commandtarpack or unpack archives
git check-attr -a <file> shows which rules apply to a file when you're not sure.
Git LFS: large files
Git stores every version of every file in every clone. For code that's tiny; for a 200 MB video edited ten times, every clone downloads 2 GB. Git LFS (Large File Storage) keeps those files on a separate server and puts a small pointer in Git instead:
$ sudo apt install git-lfs && git lfs install # once per machine $ git lfs track "*.psd" "*.mp4" "data/*.parquet" # writes the patterns to .gitattributes $ git add .gitattributes design.psd && git commit -m "Add the design file" $ git lfs ls-files # which files LFS manages
sudo apt install git-lfs && git lfs installsudorun the next command as rootaptDebian/Ubuntu package manager&&run the next command only if this one workedgitversion control
git add .gitattributes design.psd && git commit -m "Add the design file"addstage changescommitsave staged changes
# .gitattributes (written by git lfs track) *.psd filter=lfs diff=lfs merge=lfs -text *.mp4 filter=lfs diff=lfs merge=lfs -text
Commit .gitattributes before adding the large files. GitHub rejects any single file over 100 MB
without LFS, and LFS storage and bandwidth have quotas on each plan. For build output and dependencies,
LFS is the wrong tool: don't commit them at all.
Submodules: a repository inside a repository
A submodule embeds another repository at a fixed commit, for example a shared library or a theme:
$ git submodule add https://github.com/rajeev/lib vendor/lib $ cat .gitmodules [submodule "vendor/lib"] path = vendor/lib url = https://github.com/rajeev/lib $ git commit -m "Add lib as a submodule" $ git submodule status 2bd02a4d07f3c6044aed6c3b41cea2eef8357b6d vendor/lib (heads/main)
git submodule add https://github.com/rajeev/lib vendor/libgitversion control
cat .gitmodulescatprint a file
git commit -m "Add lib as a submodule"commitsave staged changes
The parent repository stores only the URL and one exact commit. Cloning doesn't fetch submodules unless you ask:
$ git clone --recurse-submodules https://github.com/rajeev/app # everything at once $ git submodule update --init --recursive # after a plain clone $ git submodule update --remote # move to the library's newest commit $ git diff --submodule Submodule vendor/lib 2bd02a4..1513134: > lib v2 $ git commit -am "Update lib to v2"
git clone --recurse-submodules https://github.com/rajeev/appgitversion controlclonecopy a repository
git commit -am "Update lib to v2"commitsave staged changes
Updating a submodule is a commit in the parent, so library changes are reviewed and deliberate. That's the
upside; the downside is that everyone has to remember --recurse-submodules. If the code is a normal
library, a package manager (npm, pip, Go modules) is usually simpler.
git worktree: two branches at once
You're halfway through a feature, with uncommitted changes everywhere, and production needs a hotfix now. The usual dance is stash, switch, fix, switch back, unstash, and hope nothing collides. Or you clone the repository a second time and download everything again.
git worktree gives one repository several working folders, each on its own branch, sharing one .git
(so no second download, and commits made in one are instantly visible in the other):
$ git status --short # in shop, on feature/coupons M pricing.py $ git worktree add ../shop-hotfix -b hotfix/tax main Preparing worktree (new branch 'hotfix/tax') HEAD is now at ba6b843 Lower tax rate to 12% for books $ git worktree list .../shop ba6b843 [feature/coupons] .../shop-hotfix ba6b843 [hotfix/tax] $ cd ../shop-hotfix && sed -i 's/0.12/0.18/' pricing.py && git commit -qam "Restore 18% tax" $ cd ../shop && git status --short # the half-done work is untouched M pricing.py $ git branch -v * feature/coupons ba6b843 Lower tax rate to 12% for books + hotfix/tax 14d4e01 Restore 18% tax main ba6b843 Lower tax rate to 12% for books
git status --shortgitversion controlstatuswhat changed
cd ../shop-hotfix && sed -i 's/0.12/0.18/' pricing.py && git commit -qam "Restore 18% tax"cdchange directory&&run the next command only if this one workedcommitsave staged changes
The + marks a branch checked out in another worktree. Git refuses to check out the same branch twice,
which protects you from two folders fighting over one branch:
$ git worktree add ../shop-again hotfix/tax fatal: 'hotfix/tax' is already used by worktree at '.../shop-hotfix' $ git worktree remove ../shop-hotfix # when done; the branch and its commits stay
git worktree add ../shop-again hotfix/taxgitversion control
Worktrees are also how people run a long test suite on one branch while working on another, or review a colleague's pull request without touching their own work. A stash is still fine for a two-minute switch.
Sparse checkout: part of a big repository
In a monorepo with fifty services, you work on one. Checking out all fifty costs disk space, slows
git status, and makes your editor index code you'll never open. Sparse checkout keeps the full history
but puts only the folders you choose in your working folder. Combined with a partial clone
(--filter=blob:none), Git doesn't even download the file contents for the rest until you need them.
$ git clone --filter=blob:none --sparse file://$PWD/mono mono-clone $ cd mono-clone && ls # only the top-level files at first README.md $ git sparse-checkout set services/orders web $ ls && ls services README.md services web orders $ git sparse-checkout list services/orders web
git clone --filter=blob:none --sparse file://$PWD/mono mono-clonegitversion controlclonecopy a repository
cd mono-clone && lscdchange directory&&run the next command only if this one workedlslist files
This clone came from a local test monorepo (three services, web and docs); a local source needs
git config uploadpack.allowFilter true for --filter, which GitHub already supports. The sparse clone's working folder was 540K
against 1.4M for a full clone, and its .git 556K against 1.1M. On a real monorepo the gap is gigabytes.
git sparse-checkout add docs widens the view, and git sparse-checkout disable brings back everything.
Commits, branches and pushes work as normal: you're still working with the whole repository, just not
looking at all of it.
GitHub Pages: publish a site
GitHub Pages serves a static website (HTML, CSS, JavaScript) straight from a repository, free, with HTTPS:
- Put an
index.htmlonmain(or in adocs/folder). - In Settings → Pages, set Source to Deploy from a branch and pick
mainand the folder. - Your site is at
https://<user>.github.io/<repo>/a minute later. A repository named<user>.github.iois served at the root.
For sites that need a build step (React, Astro, Hugo), choose GitHub Actions as the source and let a
workflow build and deploy it. A custom domain needs a CNAME DNS record and the domain in Settings →
Pages; tick Enforce HTTPS.
Harden your repository
Make the shop repository pass the checklist above: secret scanning and push protection on, Dependabot alerts and weekly updates for your dependencies and Actions, a SECURITY.md, and main protected. Then prove push protection works.
A repository that blocks leaked keys and updates itself weeklyReveal one safe solution
$ mkdir -p .github $ cat > .github/dependabot.yml <<'EOF' version: 2 updates: - package-ecosystem: pip directory: / schedule: interval: weekly cooldown: default-days: 7 - package-ecosystem: github-actions directory: / schedule: interval: weekly EOF $ printf '# Security\n\nPlease report vulnerabilities privately to security@example.com, not in public issues.\n' > SECURITY.md $ git switch -c chore/security && git add .github SECURITY.md $ git commit -m "chore: add Dependabot and a security policy" $ git push -u origin chore/security && gh pr create --fill $ gh pr merge --squash --delete-branch
mkdir -p .githubmkdirmake a directory-pcreate missing parents, no error if it exists
cat > .github/dependabot.yml <<'EOF'catprint a file>write output to a file, replacing it
git switch -c chore/security && git add .github SECURITY.mdgitversion control&&run the next command only if this one workedaddstage changes
git commit -m "chore: add Dependabot and a security policy"commitsave staged changes
git push -u origin chore/security && gh pr create --fillpushupload commits
Then in Settings → Advanced Security turn on Dependabot alerts, Dependabot security updates, secret scanning and push protection. To test push protection without a real secret, use one of the test tokens GitHub documents for this purpose, and watch the push get rejected. Put your own contact address in SECURITY.md, not the example one.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
A database password was committed and pushed an hour ago. What is the first thing to do?
Official reading for this lesson
Or get every quiz answer right and it completes itself.