Dual-repo pattern for self-hosted platforms — public portfolio, private source of truth
If you’ve been running a self-hosted platform long enough, you’ve hit this moment. You want to share something — the architecture, the dashboards, a runbook you’re proud of — but the same repo also has internal IPs, service hostnames, and a script with a hardcoded password you’ve been meaning to clean up.
You stare at the GitHub “make public” button and freeze.
The fix is not an afternoon of scrubbing git history. The fix is to treat real configs and portfolio surfaces as two different artifacts that happen to be about the same project.
The principle
Every infrastructure project gets two repos:
<project>-internal— private. Source of truth. Real configs, real IPs, work in progress, audit findings, anything that helps you actually run the thing.<project>— public. Portfolio surface. Sanitized exports, polished README, architecture diagrams with placeholder addresses.
The internal repo is where you live. You commit freely. Real data is fine — that’s what private repos are for. The public repo is curated. Every commit there is deliberate, sanitized, and meant to be read.
The naming is intentional. -internal reads as the working copy. The bare project name reads as the portfolio piece. When you link to a project from a resume or LinkedIn, you link to the bare name. The public one.
The discipline
Three rules I’ve stopped breaking:
Real data never touches the public repo. Not “redact later,” not “clean it up before pushing.” Sanitize before you push, or don’t push.
Public commits are deliberate. No wip, no fixed typo, no polish stuff. Every commit on the public side has a purpose and is meant to be read.
One folder, one remote. If a working directory pushes to the private repo, it doesn’t also have the public repo as a second remote. Two folders for two repos. Reduces the chance of accidentally pushing real configs to the public side because muscle memory hit git push from the wrong place.
Setup with gh
Assuming you have GitHub CLI installed:
brew install gh && gh auth login
For the private working copy:
# Inside the project folder where real work lives
cd ~/projects/my-project
gh repo create otengg/my-project-internal \
--private \
--description "Internal source of truth. Sanitized exports go to my-project." \
--source=. \
--remote=origin \
--push
That’s the working copy done. When you have a sanitized version ready to publish, use a separate folder:
cd ~/projects/my-project-public
git init
gh repo create otengg/my-project \
--public \
--source=. \
--remote=origin \
--push
Two folders, two remotes, no overlap. The internal repo is your day-to-day. The public repo only sees content you’ve explicitly chosen to share.
A useful side effect
Set a global gitignore once and forget about it:
cat > ~/.gitignore_global <<'EOF'
.DS_Store
*.code-workspace
.vscode/
.env
.env.local
EOF
git config --global core.excludesfile ~/.gitignore_global
That kills three classes of accidental commits across every repo on the machine: macOS metadata, VS Code workspace files, and .env files holding secrets. None of those have any business in any repo, public or private.
What this changes
Mostly, peace of mind. You stop worrying about whether that one config has an IP you forgot. You commit real work to private without performing for an audience. You publish to public when something is actually ready, not when you’re tired of the cleanup chore.
It’s a one-time mental shift. After that, every new project starts with gh repo create <name>-internal --private and the rest takes care of itself.