ddenv is a tool for setting up and maintaining a local development environment on macOS. It is an opinionated tool, and not quite easy to extend, and generally not recommended for use by anyone but me. So: why does it exist and why am I writing about it?
First, it’s possible that this tool turns out to be useful to others anyway. That’s nice, as long as I don’t start carrying the maintenance burden of this tool.
Secondly, I think ddenv is an interesting tool to examine without necessarily using it directly. I’d like it to be a conversation starter around how we manage our local development environments. I also think ddenv is architected in a rather satisfying way.
Here is a demonstration of how ddenv works:
If you are keen to try out ddenv, first ensure that you have Homebrew installed. Then, tap my Homebrew repository and install ddenv:
% brew tap denisdefreyne/tap \ https://codeberg.org/denis_defreyne/homebrew-tap.git % brew install --no-quarantine ddenv
Verify that the installation succeeded by running ddenv --version:
% ddenv --version
ddenv 0.1.11
commit 9bd1a227099f0985d26b9650b09765a7495a2158
built at 2025-08-25T08:34:16Z
In this example, we’ll set up Ruby with the given version. While there already exist tools for this exact purpose (see the alternatives section), ddenv does a little more than that.
To start using ddenv for managing a Ruby installation, first create the file .ruby-version with the content 3.4.5 (or whichever version of Ruby is the most recent).
Next, create the .config directory if it does not yet exist, and then create the file .config/ddenv.yaml with the following content:
up:
- ruby
Now, run ddenv:
% ddenv
Installing Homebrew formula ‘ruby-install’ done
Installing Ruby 3.4.5 done
Adding Shadowenv to shell done
Creating Shadowenv dir done
Adding Shadowenv dir to .gitignore done
Trusting Shadowenv done
Adding Ruby 3.4.5 to Shadowenv done
Finally, test that the right Ruby version is installed:
% ruby --version
ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [arm64-darwin24]
The example so far has shown how to install the right Ruby version based oh the contents of the .ruby-version file. This is something that other, more established tools are good at, too. But ddenv goes beyond managing software versions, because it aims to fully automate the setup of local development environments, and that means Ruby gems as well.
To illustrate this, create a file Gemfile that lists Rails as the one and only gem dependency — in other words, with the following content:
source "https://rubygems.org"
gem "rails"
Update .config/ddenv.yaml to have a bundle goal, so that after installing Ruby, ddenv will install the gems:
up:
- ruby
- bundle
Now, run ddenv:
% ddenv
Installing Homebrew formula ‘ruby-install’ skipped
Installing Ruby 3.4.5 skipped
Adding Shadowenv to shell skipped
Creating Shadowenv dir skipped
Adding Shadowenv dir to .gitignore skipped
Trusting Shadowenv skipped
Adding Ruby 3.4.5 to Shadowenv skipped
Installing Ruby gem bundler skipped
Installing bundle done
Note that ddenv skipped a handful of goals that were previously achieved. It only spent time installing the bundle (i.e. the Ruby gems listed in the Gemfile).
Imagine that at some point, the contents of Gemfile have changed upstream. Updating the local development environment means just running ddenv:
% git pull [snip] % ddenv Installing Homebrew formula ‘ruby-install’ skipped Installing Ruby 3.4.5 skipped Adding Shadowenv to shell skipped Creating Shadowenv dir skipped Adding Shadowenv dir to .gitignore skipped Trusting Shadowenv skipped Adding Ruby 3.4.5 to Shadowenv skipped Installing Ruby gem bundler skipped Installing bundle done
As before, ddenv will execute what needs to be executed to bring the local development environment up to date. This makes ddenv great to run right after a git pull.
ddenv is built around a few core ideas.
Core idea: automate everything — The local development setup must be automatable in its entirety. After running ddenv, no further work should be necessary to fully set up the local development environment.
Core idea: self heal — When the declaration of the local development setup has changed (such as an updated Ruby version in .ruby-version or an updated Gemfile.lock), running ddenv must fully bring the local development environment up to date.
Core idea: avoid redundant work — Running ddenv should do only the minimum amount of work to bring the local development environment up to date.
Core idea: be idempotent — Running ddenv should always be safe, and not make changes to the local development environment unless needed.
Core idea: reuse existing tools — Avoid reimplementing what already exist, and prefer building on top of existing tools (such as Homebrew and ruby-install).
With these core ideas put forth, it’s worth specifying what ddenv deliberately does not do.
ddenv does not create fully isolated environments. For example, when ddenv sets up PostgreSQL, then that PostgreSQL will be available system-wide and be shared between other ddenv environments that specify PostgreSQL. While creating fully isolated environments can be useful, it is not trivial to do so.
ddenv does not support bringing down an enviroment. For example, ddenv will not delete an installed Ruby version when the ruby goal is removed, and neither will it remove the node_modules when the npm goal is removed.
ddenv limits itself strictly to bringing up the local development environment. It is specifically not a task runner, unlike mise or npm.
ddenv works great for myself, but is most likely not the right choice for you. Consider what alternatives you have. A woefully incomplete list follows (with opinions):
There are version managers like adsf and mise. These are established tools for managing multiple versions of runtimes and tools, and support far more tools and languages than ddenv does. These are probably better choices than ddenv if they do what you want.
Nix (and its derivatives) is quite neat but awkward to use on macOS.
Docker is a tool for containerized deployment of applications. It is not fully open-source. It is cumbersome and inappropriate for development.
The goal is a core concept of ddenv. Two types of goals exist: achievable goals and container goals. While container goals are a collection of sub-goals, the real work happens in achievable goals. This is best illustrated with an example:
The “Ruby set up” goal (found as ruby in a ddenv.yaml file) is a container goal. Its subgoals involve instaling the ruby-install executable, running ruby-install to install Ruby, and finally setting up the Shadowenv configuration to add the new Ruby to the PATH variable, among other things.
The “Homebrew package installed” goal (found as homebrew in a ddenv.yaml file) is an achievable goal. It simply runs the relevant brew install.
Let’s dive into these two goal types in more detail.
An achievable goal implements the following interface:
type WithAchieve interface {
IsAchieved() bool
Achieve() error
}
IsAchieved() returns true if the goal has already been achieved, and false otherwise. If the goal has not been achieved, ddenv will call Achieve(), which can return an error (but hopefully not).
To illustrate achievable goals, consider a goal that creates a file with the current date in it:
type DateFileCreated struct {
FilePath string
}
IsAchieved() will return true if and only if the file at FilePath already exists, and contains the current date. Achieve() will create the file at FilePath, creating the containing directory if it does not yet exist, and write the current date to it.
A container goal implements the following interface:
type WithSubGoals interface {
SubGoals() []Goal
}
Simple.
A container goal can also be an achievable goal. Sub-goals will be handled before the Achieve() is called.
All goals (achiveable or not, container or not) must implement the Goal interface as well:
type Goal interface {
Description() string
HashIdentity() string
}
The Description() is what will be printed to the terminal.
The HashIdentity() is used for de-duplicating goals. This comes in handy when two distinct goals have a common sub-goal (such as setting up Shadowenv). The hash identity string should contain the name of the goal as well as its properties.
Some goals create files inside the .shadowenv.d directory. For example, the node goal creates and maintains .shadowenv.d/200_node.lisp.
Goals that create files inside .shadowenv.d should implement the WithManagedShadowenvFilePaths interface:
type WithManagedShadowenvFilePaths interface {
ManagedShadowenvFilePaths() []string
}
The ManagedShadowenvFilePaths() should return the paths of the files inside .shadowenv.d. The paths should include the ".shadowenv.d/" prefix. For example:
func (g MyGoal) ManagedShadowenvFilePaths() []string {
return []string{".shadowenv.d/999_my_goal.lisp"}
}
ddenv will automatically remove non-managed files. For example, when the node goal is removed from ddenv.yaml, then ddenv will remove .shadowenv.d/200_node.lisp.
Figure out extensibility. It could be useful to share plugins. Here is what that could look like — specifying plugin sources, and using namespaced goals:
up:
- core/ruby
- homebrew/formula: "graphviz"
plugins:
- core
- homebrew:
source: "https://example.com/ddenv-plugins/homebrew"
This might mean dropping Golang as the implementation language, and going for something that can handle plugins more easily.
It would be helpful for ddenv to be able to piggy-back on asdf and mise.
[To do: describe all goals. For now, refer to the README.]
There is the ddenv repository and the ddenv web site repository. To get in touch with me, refer to my contact page.
Copyright © 2025–… Denis Defreyne
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
The ddenv web site © 2025–… Denis Defreyne is licensed under Creative Commons Attribution-ShareAlike 4.0 International.