DEV Community

Cover image for GitHub Dotfiles: Treating Your Development Environment Like Code
Rodrigo de Oliveira
Rodrigo de Oliveira

Posted on

GitHub Dotfiles: Treating Your Development Environment Like Code

Most developers eventually accumulate a collection of small configuration files, aliases, scripts, shell functions, and Git preferences that make their daily work a little more comfortable.

At first, they usually live directly inside ~/.bashrc, ~/.gitconfig, random folders under ~/scripts, or maybe inside a note called something like “things to install on a new machine”.

That works surprisingly well — until you change computers.

Or start using GitHub Codespaces.

Or reinstall Linux.

Or realize that the alias you depend on every day exists on one machine but not another.

That is where dotfiles become interesting.

I maintain my development environment in a GitHub repository called dotfiles. What started as a way to keep a few preferences synchronized gradually became something more useful: a reproducible, testable, and intentionally limited development environment for Git, Bash, .NET, and GitHub Codespaces.

The important part isn't the aliases themselves.

The interesting part is treating your personal development environment with some of the same engineering practices you would use in a production repository.

Dotfiles are configuration as code

On Unix-like systems, configuration files commonly start with a dot: .bashrc, .gitconfig, .vimrc, and so on. Because they are hidden by default, they became known collectively as dotfiles.

Putting them in Git gives you some immediate advantages.

You get history. You can understand why a configuration changed.

You get portability. A new environment can start from a known baseline instead of being rebuilt from memory.

And you get rollback. When an experiment with your shell configuration goes badly, git diff is a much nicer debugging tool than trying to remember what the file looked like yesterday.

But versioning dotfiles is only the first step.

The bigger question is:

What actually belongs in a personal environment repository?

That boundary turned out to be one of the most important design decisions in my setup.

Your dotfiles should not own your projects

My repository deliberately manages developer-level preferences, not project dependencies.

For example, Git behavior and shell aliases belong to the dotfiles repository. A project's .NET SDK version does not.

If a repository requires a particular .NET SDK, that decision belongs in its global.json. NuGet package versions belong in project files or Directory.Packages.props. Local .NET tools belong in .config/dotnet-tools.json.

The same principle applies to the editor. A project may recommend VS Code extensions through .vscode/extensions.json, while personal editor preferences can remain in VS Code Settings Sync.

This separation prevents a surprisingly common problem: your personal workstation configuration slowly becoming an undocumented dependency of the applications you build.

A project should still describe how it wants to be built when another developer clones it.

Your dotfiles should make you more productive while respecting that contract.

A safe installer is more important than a clever installer

The entry point of the repository is intentionally boring:

git clone https://github.com/rodri-oliveira-dev/dotfiles.git "$HOME/dotfiles"
cd "$HOME/dotfiles"
./install.sh
source "$HOME/.bashrc"
dotfiles-doctor
Enter fullscreen mode Exit fullscreen mode

The interesting work happens behind install.sh.

Instead of replacing the entire ~/.bashrc, the installer adds a clearly delimited managed block. That block loads the repository's shell fragments and makes ~/.local/bin available through PATH.

The actual managed configuration lives under:

${XDG_CONFIG_HOME:-$HOME/.config}/rodri-dotfiles
Enter fullscreen mode Exit fullscreen mode

The installer creates links from that stable location to the files stored in the cloned repository.

That distinction matters.

A naïve dotfiles installer might simply copy a new .bashrc over the user's existing one. That is easy to implement, but fairly hostile to environments such as Codespaces, Linux distributions, corporate machines, or anything else that has already added useful shell initialization.

This repository instead tries to own only the portion it created.

If an existing destination is not one of the symlinks the repository expects to manage, installation stops rather than replacing it.

That behavior is slightly less convenient than cp -f.

It is also much harder to regret.

Idempotency changes everything

A setup script becomes considerably more useful when running it twice produces the same result as running it once.

That is the goal here.

Running install.sh repeatedly should not keep appending blocks to .bashrc, duplicating Git configuration, or creating additional links.

This is especially important with GitHub Codespaces because environment creation becomes an automated lifecycle operation rather than something you manually perform once every few years.

The repository can be configured as a GitHub Codespaces dotfiles source, allowing new Codespaces to bootstrap the personal configuration automatically.

At that point, idempotency is no longer just nice engineering hygiene.

It becomes part of the interface.

Git configuration without replacing .gitconfig

Git gets the same treatment.

Rather than owning the user's complete global configuration, the installer adds an include.path pointing at the managed Git configuration.

The repository currently defines defaults such as:

[init]
    defaultBranch = main

[fetch]
    prune = true

[push]
    autoSetupRemote = true

[rerere]
    enabled = true

[diff]
    algorithm = histogram
Enter fullscreen mode Exit fullscreen mode

These are personal defaults, not project policies.

Your employer's repository can still have its own configuration. Another tool can still add global Git settings. The dotfiles configuration participates in the final Git configuration instead of trying to become the entire configuration.

That composability is something I now consider essential in a dotfiles repository.

Small aliases are useful. Context-aware helpers are better.

Of course, the repository still contains the usual conveniences.

For Git, I use aliases such as gs, gd, and gl. For .NET, there are shortcuts such as dr for restore, db for build, and dt for tests.

Those save keystrokes.

The more interesting commands are the repository-aware helpers.

For example:

dotnet-context
Enter fullscreen mode Exit fullscreen mode

can inspect the current Git repository and report conventions such as global.json, Directory.Build.props, Directory.Packages.props, local tool manifests, and solution files.

Another command:

dotnet-repo-doctor
Enter fullscreen mode Exit fullscreen mode

provides a broader diagnostic view of a .NET repository, including SDK resolution, solutions, projects, test projects, target frameworks, Central Package Management, and other common configuration.

There is also:

dotnet-deps vulnerable
Enter fullscreen mode Exit fullscreen mode

for dependency diagnostics, and:

dotnet-why Microsoft.CodeAnalysis.Common
Enter fullscreen mode Exit fullscreen mode

for answering one of those wonderfully annoying questions:

Why is this package even in my dependency graph?

And before pushing a change, I can run:

dotnet-verify
Enter fullscreen mode Exit fullscreen mode

which coordinates restore, build, formatting verification, and tests using the repository's own configuration.

The goal is not to hide the .NET CLI.

It is to encode the repetitive discovery and safety checks that normally happen before invoking it.

Respecting global.json is a small but important detail

One temptation when creating a developer bootstrap repository is installing your favorite SDK and tool versions globally.

I intentionally avoided that.

Different projects may target different SDK feature bands. Your dotfiles should not silently decide that every repository on your machine needs the same one.

The helpers therefore resolve the SDK from the repository context instead of trying to impose one globally.

The same idea applies to .NET tools.

When a tool is part of a project's build process, a local tool manifest is a much better source of truth than someone's workstation.

This is a subtle architectural boundary, but it dramatically improves reproducibility.

Diagnostics deserve a first-class command

Automated setup is great until something becomes slightly wrong.

That is why the repository includes:

dotfiles-doctor
Enter fullscreen mode Exit fullscreen mode

The doctor checks the assumptions that make the environment work: shell integration, managed symlinks, PATH, Git includes, repository hooks, helper commands, Bash, Git, and the available .NET SDK.

This is one of my favorite patterns in developer tooling.

Installation says:

Here is how the environment should look.

A doctor command says:

Here is whether it still looks that way.

That difference becomes increasingly valuable as the environment evolves.

Instead of telling someone to inspect four configuration files and three symlinks manually, you give the environment an executable health check.

Updates are a trust boundary

Updating dotfiles sounds trivial.

You could simply run:

git pull
Enter fullscreen mode Exit fullscreen mode

and then execute the installer again.

But there is a security detail hiding inside that workflow: the repository contains executable shell scripts.

Updating the repository therefore changes code that you may execute automatically.

The dotfiles-update helper treats that as a trust boundary.

It requires a clean worktree, verifies the expected main branch and origin/main upstream, validates that origin points to the expected repository, rejects unexpected URL rewriting, fetches the update, requires a fast-forward, verifies expected executable files, and only then refreshes the installation.

Afterward, it runs dotfiles-doctor.

This does not magically make remote code trustworthy. That would be an unrealistic claim.

It does make the transition from one trusted local revision to another much more explicit.

That is a useful distinction whenever an updater ends by executing newly downloaded code.

Uninstalling cleanly is part of installation design

There is another feature that configuration scripts often forget:

./uninstall.sh
Enter fullscreen mode Exit fullscreen mode

The uninstaller removes what the repository owns and leaves unrelated configuration alone.

That requirement influences the entire architecture.

If you cannot identify which lines, files, symlinks, and Git configuration entries belong to your installer, safely removing them later becomes difficult.

In other words, a good uninstall story is usually evidence of a good ownership model.

Dotfiles can leak more than you expect

There is one rule worth repeating whenever dotfiles and GitHub appear in the same sentence:

Do not commit secrets.

Shell configuration is exactly the kind of place where credentials tend to accumulate over time.

A quick export API_KEY=... can become permanent history surprisingly easily.

The repository therefore treats secret scanning as part of CI rather than relying purely on memory.

Its validation pipeline currently includes Gitleaks for secret detection, actionlint for GitHub Actions validation, zizmor for workflow security auditing, and Hadolint for the lifecycle test Dockerfile.

Shell scripts are additionally checked using Bash syntax validation, ShellCheck, and shfmt.

The important lesson is not that every dotfiles repository needs exactly those tools.

The lesson is that configuration repositories can execute code and can contain credentials, which means they deserve a security model too.

Yes, I test my dotfiles

This may sound excessive until you consider what an installer does.

It modifies shell startup behavior, Git configuration, symlinks, executable paths, and potentially every development environment you create.

That is worth testing.

The repository uses Bats to verify behavior such as installer idempotency, lifecycle operations, repository discovery, updater behavior, and the .NET helpers.

It also builds a clean Ubuntu test container.

That container verifies the lifecycle from a fresh environment: installation, diagnostics, idempotency, repository hooks, and uninstall.

There is even a deny-by-default Docker context so unrelated local files are not accidentally copied into the lifecycle image.

At some point, dotfiles stop being “some configuration files”.

They become a small software product whose user happens to be you.

And once you see them that way, tests stop looking strange.

What I would recommend starting with

You absolutely do not need all of this on day one.

Start with the configuration that causes friction when you move between machines.

Put your Git preferences and shell aliases in a repository. Add a simple bootstrap script. Make it safe to execute twice.

Then resist the temptation to turn the repository into a universal package manager for your entire workstation.

Add automation only when you repeatedly encounter a problem worth automating.

The repository should grow with your workflow, not become another environment you have to maintain for its own sake.

Quick takeaways

If you remember only a few things from this article:

  • Treat dotfiles as personal configuration as code, not as a replacement for project configuration.
  • Make installation idempotent and conservative; avoid overwriting configuration you do not own.
  • Keep SDKs, packages, tools, and other project dependencies inside the project that needs them.
  • Give automated environments a doctor command so assumptions can be verified later.
  • Consider updates to executable dotfiles a trust boundary.
  • Never store credentials in a public dotfiles repository, and consider automated secret scanning.
  • Once your installer starts changing important parts of your environment, test it like software.

Where to go next

If you want to explore the implementation discussed here, the complete repository is available at rodri-oliveira-dev/dotfiles on GitHub. The most interesting places to start are README.md, install.sh, bin/dotfiles-doctor, bin/dotfiles-update, shell/, git/config, .github/workflows/validate.yml, and tests/.

For the broader concepts, GitHub's documentation about personalizing Codespaces with dotfiles is an excellent next step. The Git documentation for configuration includes is useful for understanding the include.path approach used here, while ShellCheck is worth studying if you maintain non-trivial Bash scripts.

Bats is also a surprisingly useful tool to learn if you want executable tests around shell behavior. And if your dotfiles are stored remotely — especially in a public repository — studying secret scanning tools such as Gitleaks is time well spent.

The main idea, however, is simpler than any particular tool.

A development environment is part of the way we build software. Once we make that environment reproducible, observable, and intentionally scoped, moving to a new machine stops being a rebuilding exercise and becomes something much closer to deploying a known configuration.

And that is a much nicer place to be.

Top comments (0)