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.

75-second introduction

Here is a demonstration of how ddenv works:

Installation

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

Examples

Example 1: Managing Ruby

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]

Example 2: Managing gems

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).

Example 3: Upgrading gems

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.

Philosophy

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).

Non-goals

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.

Alternatives

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.

Architecture

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.

Achievable goals

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.

Container goals

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.

Generic goals

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.

Managed paths

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.

Future work

Extensibility

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.

Piggy-backing

It would be helpful for ddenv to be able to piggy-back on asdf and mise.

Reference

[To do: describe all goals. For now, refer to the README.]

External resources

There is the ddenv repository and the ddenv web site repository. To get in touch with me, refer to my contact page.

Legal notice

ddenv

Copyright © 2025–…

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/>.

ddenv web site

The ddenv web site © 2025–… is licensed under Creative Commons Attribution-ShareAlike 4.0 International.