DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    Git & GitHubLesson 13 of 13
    Your progress · 0%
    On this page
    1. Secrets: keep them out, and respond fast
    2. Dependabot: patched dependencies every week
    3. Signed commits
    4. .gitattributes: rules per file
    5. Git LFS: large files
    6. Submodules: a repository inside a repository
    7. git worktree: two branches at once
    8. Sparse checkout: part of a big repository
    9. GitHub Pages: publish a site
    ↖ Course roadmap
    Lesson 13 · Security & extras

    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.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 5 complete

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

    Four layers, each catching what the one before missed. And a plan for when all four fail.© Diagram: DevOps Academy

    Four layers, each catching what the one before missed. And a plan for when all four fail.© Diagram: DevOps Academy
    1. Never in code. Read secrets from environment variables or a secrets manager. .env files go in .gitignore (the Commits lesson), with a committed .env.example that lists the names and no values.
    2. Block on your laptop with a pre-commit hook such as gitleaks (the Hooks lesson).
    3. 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
    
    1. 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

    OrderDoWhy
    1Rotate it: create a new key, deploy it, revoke the old onethe leaked one becomes worthless
    2Check the provider's logs for use of the old keywas it already abused?
    3Remove it from the code and commitstop it spreading to new clones
    4If required, purge it from history with git filter-repo and force-pushcleans 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 ssh
    • gitversion control
    • configread 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_signers
    • echoprint text
    • >>append output to a file
    git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
    • gitversion control
    • configread 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 -1
    • gitversion control
    • loghistory

    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' -2
    • gitversion control
    • loghistory

    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 control
    • addstage 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.json
    • gitversion 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/coupons
    • gitversion 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 worked
    • commitsave staged changes
    cat CHANGELOG.md
    • catprint 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 -t
    • gitversion control
    • |pipe: send output to the next command
    • tarpack 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 install
    • sudorun the next command as root
    • aptDebian/Ubuntu package manager
    • &&run the next command only if this one worked
    • gitversion control
    git add .gitattributes design.psd && git commit -m "Add the design file"
    • addstage changes
    • commitsave 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/lib
    • gitversion control
    cat .gitmodules
    • catprint 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/app
    • gitversion control
    • clonecopy 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 --short
    • gitversion control
    • statuswhat 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 worked
    • commitsave 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/tax
    • gitversion 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-clone
    • gitversion control
    • clonecopy a repository
    cd mono-clone && ls
    • cdchange directory
    • &&run the next command only if this one worked
    • lslist 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:

    1. Put an index.html on main (or in a docs/ folder).
    2. In Settings → Pages, set Source to Deploy from a branch and pick main and the folder.
    3. Your site is at https://<user>.github.io/<repo>/ a minute later. A repository named <user>.github.io is 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.

    A secure repository, as a checklist

    Two-factor authentication for everyone. Main protected by a ruleset with reviews and required checks. Secret scanning and push protection on. Dependabot alerts, security updates and weekly version updates on. Actions pinned to commit SHAs, and .github/workflows/ in CODEOWNERS. A SECURITY.md saying how to report a vulnerability privately. This site's own repository follows exactly this list.

    ⌘
    Mini mission

    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.

    Input: The shop repository on GitHubOutput: A repository that blocks leaked keys and updates itself weekly
    Reveal 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 .github
    • mkdirmake 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.md
    • gitversion control
    • &&run the next command only if this one worked
    • addstage changes
    git commit -m "chore: add Dependabot and a security policy"
    • commitsave staged changes
    git push -u origin chore/security && gh pr create --fill
    • pushupload 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 7

    A database password was committed and pushed an hour ago. What is the first thing to do?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: about secret scanningGitHub Docs ↗
    • DocsGitHub Docs: about push protectionGitHub Docs ↗
    • DocsGitHub Docs: Dependabot options referenceGitHub Docs ↗
    • ToolGit LFSGit LFS ↗
    • GuidePro Git: submodulesPro Git ↗
    • DocsGitHub Docs: quickstart for GitHub PagesGitHub Docs ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousHooks and automation: let Git check your workUp nextBack to the course roadmap