Tools used with DevContainers such as, Mise, Chezmoi, and Neovim - including all the troubleshooting, mistakes, and lessons learned along the way.
This article documents my journey setting up a portable development environment. Personal references (usernames, repo names, paths) have been generalized to make the content applicable to any reader.
Table of Contents
- Introduction
- The Initial Goal
- The Technology Stack
- The Setup Process
- Troubleshooting Chronicles
- Integration with Obsidian
- Making It Portable
- Final Architecture
- Lessons Learned
- Resources
Introduction
I wanted to build a portable development environment that I could replicate across any project or machine. What seemed like a simple container setup turned into a deep dive into dotfiles management, tool installation strategies, and the intricacies of containerized development environments.
This post documents the entire journey - not just the final working solution, but all the problems encountered, and how I eventually arrived at a truly portable, reproducible development environment.
The Initial Goal
What I wanted:
- A consistent, modern terminal setup
- Portable configuration that works across any project
- Integration with my existing Obsidian note-taking system
- Neovim with LazyVim for editing
- ZSH with a modern prompt (Starship)
- Ability to run Docker within the dev container
The Challenge: Starting with minimal DevContainer experience, I needed to understand:
- How DevContainers work
- How to manage dotfiles across containers
- How tool installation should be orchestrated
- How to separate project-level and personal configurations
The Technology Stack
Core Components
DevPod - A lightweight alternative to VS Code dev containers
- Simpler than VS Code’s implementation
- Works from the command line
- No IDE lock-in
- Manages multiple workspace environments
- Provider-agnostic (Docker, Kubernetes, SSH, etc.)
Mise - Modern development tool version manager
- Replaces asdf, nvm, rbenv, etc.
- Supports multiple registries (aqua, core, cargo)
- Can run tasks like Make
- Two-level configuration strategy
Chezmoi - Dotfiles manager
- Templates and external sources
- Git-based synchronization
- Handles file permissions correctly
- Can run installation scripts
LazyVim - Neovim distribution
- Pre-configured plugins
- Sensible defaults
- Extensible plugin system
Supporting Tools
- Pure - ZSH prompt theme
- Telescope - Fuzzy finder for Neovim
- Tmux - Terminal multiplexer
- Ripgrep - Fast text search
- FZF - Command-line fuzzy finder
- Bat - Better cat with syntax highlighting
The Setup Process
Phase 0: Installing and Configuring DevPod
I needed to install DevPod and understand how it manages development environments.
Installation:
DevPod supports multiple platforms. On Linux:
| |
Understanding DevPod Workspace Management:
DevPod treats each project directory as a potential “workspace”. When you run devpod up in a directory, it:
- Reads the
.devcontainer/devcontainer.jsonconfiguration - Builds a container based on that configuration
- Names the workspace after the directory (by default)
- Manages the lifecycle of that container
Key DevPod Commands:
| |
Example workspace listing:
| |
Provider Configuration:
DevPod uses “providers” to create containers. The Docker provider is simplest for local development:
| |
DevPod Configuration Files:
DevPod stores its configuration in ~/.devpod/:
~/.devpod/
├── contexts/ # Different DevPod contexts
├── provider/ # Provider configurations
└── workspaces/ # Workspace metadata
The .devcontainer Directory:
Every project with DevPod needs a .devcontainer/ directory:
project/
├── .devcontainer/
│ ├── devcontainer.json # Main configuration
│ └── Dockerfile # Container definition (optional)
├── [your project files]
At minimum, devcontainer.json needs:
| |
But as you’ll see, we’ll use a custom Dockerfile for more control.
Phase 1: Basic Container Structure
Started with a minimal Dockerfile:
| |
Why this base?
- Ubuntu 24.04 LTS for stability
- Pre-installed with common dev tools
- Uses
vscodeuser (non-root by default) - Mise activation happens automatically on shell startup
Phase 2: Project-Level Configuration
Created mise.toml in the project root:
| |
The Strategy:
- Minimal project requirements (just chezmoi)
- Setup task orchestrates the rest
- Chezmoi pulls personal configuration
Phase 3: Setup Script
The setup script handles system packages and initialization:
| |
Phase 4: Dotfiles Repository
Created a separate dotfiles repository with:
dotfiles/
├── .chezmoiexternals/
│ └── neovim.toml # LazyVim starter configuration
├── .chezmoiscripts/
│ └── install_packages.sh # Install additional tools via mise
├── dot_bashrc
├── dot_zshrc
├── dot_vimrc
├── dot_config/
│ ├── mise/
│ │ └── config.toml # Personal tool definitions
│ ├── nvim/
│ │ └── lua/
│ │ └── plugins/ # Neovim plugin overrides
│ └── starship/
└── dot_local/
└── bin/ # Personal scripts
Key Innovation: Two-Level Mise Configuration
Project level defines baseline:
| |
Personal level defines everything else:
| |
Phase 5: DevContainer Configuration
The devcontainer.json ties everything together:
| |
Mount Strategy:
- SSH keys for git authentication
- Obsidian vault for integrated note-taking
- Both use bind mounts for instant sync
Troubleshooting Chronicles
Issue 1: Terminal Getting Stuck
Problem: After enabling vi-mode in ZSH, the terminal appeared to hang after pressing Enter.
Diagnosis: Vi-mode defaults to command mode. The cursor was waiting for input but I was in the wrong mode.
Solution: Press i to enter insert mode. Added this to my muscle memory.This sounds trivial but its essential to understaning Vim
Learning: Shell modes are powerful but require understanding the mental model. Document mode transitions.
Issue 2: Tool Activation vs Installation
Problem: Shell environment not reflecting installed tools.
Investigation:
| |
Root Cause: Tools weren’t being initialized in .zshrc.
Solution: Added proper initialization to dotfiles:
| |
Learning: Tool installation ≠ tool activation. Many CLI tools require explicit initialization in your shell configuration.
Issue 3: Neovim Wasn’t Actually Neovim
Problem: Typing v opened basic vim, not LazyVim.
The Confusion: I thought I had Neovim installed.
Reality Check:
| |
Root Cause: I had created Neovim configuration files but never actually installed Neovim itself via mise.
Solution: Added neovim = "latest" to personal mise config.
Learning: Configuration without installation accomplishes nothing. Always verify tools are actually installed before debugging configuration.
Issue 4: GitHub API Rate Limiting
Problem: Repeated container rebuilds failed with 503 errors:
HTTP status server error (503 Service Unavailable)
aqua:neovim/neovim@latest: failed to download
Root Cause: GitHub rate limits API requests. Rebuilding containers 10+ times in an hour exhausted the free tier quota.
Workaround: Wait for rate limit to reset (shown in error message).This can be anything from 10 minutes to 30 minutes depending.
Better Solution:
- Fix multiple issues before rebuilding
- Use
sshinto existing container for testing - Make changes incrementally
- Cache is your friend - DevPod reuses layers
Learning: Cloud services have limits. Rapid iteration has costs. Plan your rebuilds strategically.
Issue 5: ZSH Parse Errors After Merge
Problem:
| |
Root Cause: unresolved merge conflict markers in .zshrc.
Why It Happened: Editing dotfiles both locally and from inside containers created divergent changes.
Solution:
- Chose a single source of truth (host machine at
~/github-repos/dotfiles) - Never edit chezmoi-managed files directly
- Always edit in the dotfiles repo, then
chezmoi apply
Learning: Dotfiles management requires discipline. Pick one workflow and stick to it.
Issue 6: Telescope Not Found
Problem:
E492: Not an editor command: Telescope find_files
Root Cause: LazyVim was installed but Telescope plugin wasn’t explicitly configured.
Solution: Created plugin configuration:
| |
Then ran :Lazy sync in Neovim.
Learning: Plugin distributions like LazyVim provide a framework, but you still need to explicitly add the plugins you want. Check the plugin list carefully.
Issue 7: Aliases Not Working After Dotfiles Update
Problem: After fixing .zshrc and rebuilding, the nf and ng aliases still didn’t work.
Diagnosis:
| |
Root Cause: Shell wasn’t reloaded after chezmoi applied changes.
Solutions:
| |
Learning: Configuration changes don’t apply automatically. The shell reads config files at startup. Reload explicitly after changes.
Issue 8: Wrong Workspace Path in New Container
Problem: Creating k8s-lab container failed:
[setup] $ /workspaces/devcontainer-pybash/setup
sh: 1: /workspaces/devcontainer-pybash/setup: not found
Root Cause: The mise.toml had hardcoded path:
| |
But the new container workspace was /workspaces/k8s-lab/.
Solution: Update path for each project:
| |
Learning: When copying configuration between projects, search for hardcoded paths. Some configuration is project-specific even when most is portable.
Integration with Obsidian
One of my requirements was seamless note-taking integration. I use Obsidian with a PARA structure (Projects, Areas, Resources, Archive) for knowledge management.
The Mount Strategy
| |
This gives bidirectional sync:
- Edit in Neovim inside container → Changes appear in Obsidian on host
- Edit in Obsidian on host → Changes available in container immediately
Note-Taking Aliases
Added to .zshrc:
| |
The Workflow
During development:
| |
Note structure:
~/notes/
├── 0 Inbox/ # Quick captures
├── 1 Projects/ # Project-specific notes
│ ├── k8s-lab/
│ └── my-first-project/
├── 2 Areas/ # Ongoing responsibilities
│ ├── Homelab/
│ └── TechBlog/
├── 3 Resources/ # Reference material
└── 4 Archive/ # Completed items
Helper Scripts
Created nn script for quick note creation:
| |
Usage:
| |
Making It Portable
The final test: Can I replicate this setup in a new project?
Creating the Second Container
| |
What Transfers Automatically
Personal Configuration (via dotfiles repo):
- ZSH config with aliases
- Neovim with LazyVim
- Starship prompt
- Personal mise tools (kubectl, fzf, bat, ripgrep, etc.)
- Custom scripts in
~/.local/bin/
Mounted Resources:
- SSH keys (for git authentication)
- Obsidian vault (at
~/notes)
Container Features:
- Docker-in-Docker
- ZSH as default shell
- Tmux installed
What Needs Per-Project Adjustment
Project-Specific Paths:
- Workspace path in
mise.tomlsetup task - Any hardcoded references to the project directory
The Result
| |
Two completely independent containers, identical configuration, different projects. Adding a third would take less than 5 minutes.
Final Architecture
Repository Structure
GitHub Repositories:
├── dotfiles/ # Personal configuration
│ ├── dot_zshrc
│ ├── dot_config/
│ │ ├── mise/config.toml # Personal tools
│ │ └── nvim/ # Neovim config
│ ├── dot_local/bin/ # Personal scripts
│ └── .chezmoiexternals/ # LazyVim starter
│
├── my-first-project/ # First project
│ ├── .devcontainer/
│ │ ├── Dockerfile
│ │ └── devcontainer.json
│ ├── mise.toml # Project tools
│ ├── setup # Setup script
│ └── [project files]
│
└── k8s-lab/ # Second project
├── .devcontainer/
│ ├── Dockerfile
│ └── devcontainer.json
├── mise.toml
├── setup
└── [project files]
Startup Sequence
When running devpod up .:
Build Phase
- DevPod reads
.devcontainer/devcontainer.json - Builds image from Dockerfile
- Installs mise from image
- DevPod reads
Container Start
- Mounts SSH keys from host
- Mounts Obsidian vault from host
- Sets up Docker-in-Docker
Post-Create Command
1mise trust && mise install && mise run setupmise trust: Trusts the project’s mise.tomlmise install: Installs tools (chezmoi)mise run setup: Executes setup script
Setup Script
- Installs system packages (tmux)
- Initializes chezmoi with dotfiles repo
- Changes default shell to zsh
- Installs pure prompt
Chezmoi Apply
- Clones dotfiles repository
- Applies all dotfiles templates
- Runs
.chezmoiscripts/install_packages.sh - Installs personal mise tools (bat, kubectl, fzf, neovim, etc.)
- Pulls LazyVim starter configuration
Ready State
- ZSH with custom prompt active
- Neovim with LazyVim available
- All personal tools installed
- Aliases and scripts ready
- Notes accessible at
~/notes
Configuration Layers
┌─────────────────────────────────────┐
│ Project Layer │
│ - Dockerfile │
│ - devcontainer.json │
│ - mise.toml (chezmoi, starship) │
│ - setup script │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Personal Layer (Dotfiles) │
│ - .zshrc, .bashrc, .vimrc │
│ - mise tools (kubectl, fzf, etc.) │
│ - neovim configuration │
│ - custom aliases & scripts │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Host Mounts │
│ - SSH keys │
│ - Obsidian vault │
└─────────────────────────────────────┘
Lessons Learned
1. Separate Project and Personal Concerns
The Pattern:
- Project repos define minimum requirements
- Personal dotfiles repo contains preferences
- Chezmoi bridges the gap
Why It Works:
- Project configuration is shareable
- Personal configuration stays private
- Changes to either don’t affect the other
2. Tool Installation vs Configuration
The Mistake: Creating configuration files without installing tools.
The Learning: Always verify:
| |
Configuration is worthless without the underlying tool.
3. Understanding the Container Boundary
What Happens Where:
- Container: Tool execution, file operations, shell environment
- Host: Font rendering, terminal emulation, file storage (via mounts)
- Bridge: SSH keys and Obsidian vault (mounted from host)
Impact: Some problems (like font rendering) can’t be solved in the container. Know the architecture.
4. Rate Limits Are Real
The Problem: Cloud services limit API usage.
The Strategy:
- Batch your changes
- Test in running containers when possible
- Use Docker layer caching
- Wait when you hit limits (they reset)
5. Version Control Everything
What to Track:
- Dotfiles (obviously)
- DevContainer configuration
- Setup scripts
- Even experimental configurations
Why: Git history becomes your troubleshooting log. “What changed between the working version and now?”
6. Documentation Pays Off
This Blog Post Exists Because:
- I documented problems as I hit them
- I saved error messages
- I noted what didn’t work and why
Future Benefit: When I hit similar issues (or help someone else), the solutions are documented.
7. Mise’s Two-Level Strategy Is Powerful
Project Level: Defines team requirements
| |
Personal Level: Defines your preferences
| |
Everyone gets their preferred tools without cluttering project config.
8. Chezmoi Externals Are Underrated
The Feature: Pull configuration from external sources.
Example: LazyVim starter config
| |
Benefit: Maintain base configs separately, personalize via overrides.
9. Aliases Are Force Multipliers
Before:
| |
After:
| |
Investment: 5 minutes to create alias. Return: Saved 30 seconds, dozens of times per day, forever.
10. Perfect Is the Enemy of Done
The Temptation: Rebuild containers endlessly chasing perfection.
The Reality: A working 95% solution that you actually use beats a theoretical 100% solution you never finish.
Example: The font rendering issue. Could have obsessed over it for hours. Instead: 5-minute fix on host machine, moved on.
Common Pitfalls to Avoid
1. Editing Dotfiles in Multiple Places
Wrong:
- Edit
.zshrcin container - Edit
dot_zshrcin dotfiles repo - Confusion when they diverge
Right:
- Edit only in dotfiles repo
- Run
chezmoi applyto sync - Container gets changes automatically
2. Forgetting to Reload After Config Changes
Wrong:
| |
Right:
| |
3. Installing Tools Without Activation
Wrong:
| |
Right:
| |
4. Hardcoding Paths
Wrong:
| |
Better: Use project-specific mise.toml and remember to update paths when copying.
Best: Use environment variables where possible:
| |
5. Not Checking Tool Installation
Wrong:
| |
Right:
| |
Quick Reference
Essential Commands
| |
File Locations
| |
Dotfiles Template Structure
dotfiles/
├── .chezmoiignore # Files to skip
├── .chezmoiexternals/
│ └── neovim.toml # External configs
├── .chezmoiscripts/
│ └── install_packages.sh # Installation script
├── dot_bashrc # → ~/.bashrc
├── dot_zshrc # → ~/.zshrc
├── dot_vimrc # → ~/.vimrc
├── dot_config/
│ ├── mise/
│ │ └── config.toml # → ~/.config/mise/config.toml
│ ├── nvim/
│ │ └── lua/
│ │ ├── config/ # → ~/.config/nvim/lua/config/
│ │ └── plugins/ # → ~/.config/nvim/lua/plugins/
│ └── starship/
│ └── starship.toml # → ~/.config/starship/starship.toml
└── dot_local/
└── bin/
├── executable_nn # → ~/.local/bin/nn (note creation)
└── executable_notes # → ~/.local/bin/notes (note browser)
Resources
Official Documentation
- DevPod - Local development environments
- Mise - Development tool version manager
- Chezmoi - Dotfiles manager
- LazyVim - Neovim distribution
- Starship - Cross-shell prompt
- Telescope - Fuzzy finder for Neovim
Related Tools
- Nerd Fonts - Fonts with programming ligatures and icons
- Pure Prompt - Minimal ZSH prompt
- Ripgrep - Fast grep alternative
- FZF - Command-line fuzzy finder
- Bat - Cat with syntax highlighting
Conclusion
Building this portable development environment took significantly longer than expected. What I thought would be a few hours of configuration turned into days of troubleshooting, learning, and iteration.
- Consistency: Every project starts with the same powerful environment
- Portability: Can replicate on any machine in minutes
- Isolation: Projects don’t interfere with each other
- Integration: Notes and code live side-by-side
- Learning: Deep understanding of development tooling
This took me a few weeks to get my head around it. DevContainers, dotfiles management, and development tooling are deep topics. You’ll hit problems I didn’t encounter, and you’ll solve problems I struggled with more easily.
My Advice:
Start Simple: Begin with a basic Dockerfile and devcontainer.json. Add complexity gradually.
Document Everything: Keep notes on what you’re doing and why. When something breaks, you’ll want that history.
Embrace Iteration: Your first attempt won’t be perfect. That’s fine. Each rebuild teaches you something.
Separate Concerns: Keep project config separate from personal config. Future you will thank present you.
Test Before Committing: Make sure changes work before pushing to your dotfiles repo. A broken dotfiles repo breaks all your containers.
Use Version Control: Commit often. Git history is your safety net.
Read Error Messages: Really read them. They usually tell you exactly what’s wrong.
Know When to Stop: Perfect is the enemy of done. Ship it and be happy with the imperfections