Replacing Dev Containers: Reducing Setup Time from Minutes to Seconds
Dev Containers were supposed to make onboarding painless. Everyone gets the same environment. No "works on my machine" problems. In practice, they gave us a different set of problems.
At MilkStraw AI, we're a small team building an AWS cost optimization platform. We help companies understand and reduce their cloud spend. Our Rails app ran inside a devcontainer that bundled Ruby, Node, PostgreSQL, Redis, the AWS CLI (we use it constantly), and a dozen VS Code extensions. It worked, technically. But we were debugging devcontainer issues on a weekly basis. Not big outages, just a steady drip of "my container won't start" or "the database connection is refusing" or "I rebuilt and now a gem won't install."
Eventually we asked: why are we running our entire app inside a container when the only things that actually need containers are PostgreSQL and Redis?
We replaced the whole thing with mise. One config file. Native speed. Editor-agnostic. Setup went from 5-10 minutes to under 30 seconds.
What Dev Containers Actually Looked Like
Our setup had four files under .devcontainer/. Here's what they looked like.
The Dockerfile built a custom Ruby image, installed the Stripe CLI, and cleaned up after itself:
ARG RUBY_VERSION=3.4.2
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
USER root
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends curl software-properties-common \
&& wget https:
&& dpkg -i ./stripe_cli.deb \
&& apt-get clean \
&& rm -rf /var/lib/apt/listsARG RUBY_VERSION=3.4.2
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
USER root
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends curl software-properties-common \
&& wget https:
&& dpkg -i ./stripe_cli.deb \
&& apt-get clean \
&& rm -rf /var/lib/apt/listsARG RUBY_VERSION=3.4.2
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
USER root
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends curl software-properties-common \
&& wget https:
&& dpkg -i ./stripe_cli.deb \
&& apt-get clean \
&& rm -rf /var/lib/apt/listsThe docker-compose.yml orchestrated three services: the app container, PostgreSQL, and Redis:
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
redis:
image: redis:7.2.3
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
redis:
image: redis:7.2.3
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
redis:
image: redis:7.2.3
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data
The devcontainer.json tied it all together with features, port forwarding, environment variables, VS Code settings, and extensions:
{
"name": "MilkstrawWebApp",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node": {},
"ghcr.io/devcontainers/features/docker-in-docker": {},
"ghcr.io/rails/devcontainer/features/activestorage": {},
"ghcr.io/rails/devcontainer/features/postgres-client": {},
"ghcr.io/devcontainers/features/aws-cli": {},
"ghcr.io/devcontainers/features/common-utils:latest": {
"configureZshAsDefaultShell": true
}
},
"containerEnv": {
"AWS_ACCESS_KEY_ID": "${localEnv:AWS_ACCESS_KEY_ID}",
"AWS_SECRET_ACCESS_KEY": "${localEnv:AWS_SECRET_ACCESS_KEY}"
},
"forwardPorts": [3000, 5432],
"postCreateCommand": ".devcontainer/setup.sh",
"customizations": {
"vscode": {
}
}
}{
"name": "MilkstrawWebApp",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node": {},
"ghcr.io/devcontainers/features/docker-in-docker": {},
"ghcr.io/rails/devcontainer/features/activestorage": {},
"ghcr.io/rails/devcontainer/features/postgres-client": {},
"ghcr.io/devcontainers/features/aws-cli": {},
"ghcr.io/devcontainers/features/common-utils:latest": {
"configureZshAsDefaultShell": true
}
},
"containerEnv": {
"AWS_ACCESS_KEY_ID": "${localEnv:AWS_ACCESS_KEY_ID}",
"AWS_SECRET_ACCESS_KEY": "${localEnv:AWS_SECRET_ACCESS_KEY}"
},
"forwardPorts": [3000, 5432],
"postCreateCommand": ".devcontainer/setup.sh",
"customizations": {
"vscode": {
}
}
}{
"name": "MilkstrawWebApp",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node": {},
"ghcr.io/devcontainers/features/docker-in-docker": {},
"ghcr.io/rails/devcontainer/features/activestorage": {},
"ghcr.io/rails/devcontainer/features/postgres-client": {},
"ghcr.io/devcontainers/features/aws-cli": {},
"ghcr.io/devcontainers/features/common-utils:latest": {
"configureZshAsDefaultShell": true
}
},
"containerEnv": {
"AWS_ACCESS_KEY_ID": "${localEnv:AWS_ACCESS_KEY_ID}",
"AWS_SECRET_ACCESS_KEY": "${localEnv:AWS_SECRET_ACCESS_KEY}"
},
"forwardPorts": [3000, 5432],
"postCreateCommand": ".devcontainer/setup.sh",
"customizations": {
"vscode": {
}
}
}And finally a setup.sh that ran after the container was created:
mkdir -p .ruby-lsp
cp .ruby-version .ruby-lsp/.ruby-version
test -f .env || cp .env.example .env
gem update --system
mkdir -p .ruby-lsp
cp .ruby-version .ruby-lsp/.ruby-version
test -f .env || cp .env.example .env
gem update --system
mkdir -p .ruby-lsp
cp .ruby-version .ruby-lsp/.ruby-version
test -f .env || cp .env.example .env
gem update --system
All told, about 360 lines of config spread across four files just to open the project.
Why We Left
Docker within Docker. This was the big one. Our app ran inside a container, but PostgreSQL and Redis ran as their own containers in the same compose stack. That meant Docker-in-Docker as a devcontainer feature, compose networking between services, and volume mounts for data persistence.
Getting this to work reliably was a constant fight. Containers couldn't find each other. Ports conflicted. Volumes had permission problems. All just to get a database connection.
Slow and heavy. First-time setup took 5-10 minutes: image build, feature installation, post-create script. Rebuilds after Dockerfile changes weren't much better.
Our team runs macOS, which doesn't run Docker natively. Everything goes through a Linux VM under the hood. File system performance through bind mounts was sluggish. Docker Desktop ate significant disk space, CPU, and RAM just sitting in the background. You're running your entire development environment inside a VM. That has a cost, and we were paying it on every keystroke.
Endless debugging across layers. When something broke, you had to figure out which layer was responsible. The Dockerfile? The Docker-in-Docker networking? The compose config? The post-create script? A devcontainer feature conflict? A volume mount permission issue?
I've watched team members lose entire mornings to container rebuild loops where each attempt surfaced a new error. Dev Containers were supposed to eliminate environment problems. Instead, we swapped one class of problems for another that was harder to debug.
Editor lock-in. Everything was wired to VS Code. The settings, the formatters, the run-on-save commands. If you wanted to use RubyMine or Zed, you were on your own.
Version drift. Ruby version was hardcoded in the Dockerfile, referenced in .ruby-version, and needed in CI. Three places to keep in sync.
What We Replaced It With
One file: mise.toml. It replaced not just the devcontainer, but also .ruby-version, .node-version, and the scattered shell scripts we used for common tasks. One tool instead of four. (The fact that this was even possible surprised us.)
[tools]
ruby = "3.4.2"
node = "20.15.0"
yarn = "1.22.22"
stripe = "latest"
aws-cli = "latest"
[tasks.dev]
description = "Start development environment"
run = '''
mise run docker:start
mise run deps:js
mise run deps:ruby
mise run db:prepare
cleanup() {
mise docker:stop
exit 0
}
trap cleanup SIGINT SIGTERM
./bin/dev
cleanup
'''
[tasks."docker:start"]
run = "docker compose -f dev-docker-compose.yml up -d"
[tasks."docker:stop"]
run = "docker compose -f dev-docker-compose.yml down"[tools]
ruby = "3.4.2"
node = "20.15.0"
yarn = "1.22.22"
stripe = "latest"
aws-cli = "latest"
[tasks.dev]
description = "Start development environment"
run = '''
mise run docker:start
mise run deps:js
mise run deps:ruby
mise run db:prepare
cleanup() {
mise docker:stop
exit 0
}
trap cleanup SIGINT SIGTERM
./bin/dev
cleanup
'''
[tasks."docker:start"]
run = "docker compose -f dev-docker-compose.yml up -d"
[tasks."docker:stop"]
run = "docker compose -f dev-docker-compose.yml down"[tools]
ruby = "3.4.2"
node = "20.15.0"
yarn = "1.22.22"
stripe = "latest"
aws-cli = "latest"
[tasks.dev]
description = "Start development environment"
run = '''
mise run docker:start
mise run deps:js
mise run deps:ruby
mise run db:prepare
cleanup() {
mise docker:stop
exit 0
}
trap cleanup SIGINT SIGTERM
./bin/dev
cleanup
'''
[tasks."docker:start"]
run = "docker compose -f dev-docker-compose.yml up -d"
[tasks."docker:stop"]
run = "docker compose -f dev-docker-compose.yml down"Mise installs Ruby, Node, Yarn, Stripe CLI, and AWS CLI natively on your machine. No container image to build. Docker is still there, but only for what actually needs it: PostgreSQL and Redis as lightweight background services.
A new developer runs two commands:
mise install takes about 30 seconds. mise dev starts the full environment in under 10 seconds. Day-to-day, it's instant.
360 lines of config became about 100. One PR. No drama.
What We Gained
The immediate win was simplicity. One file defines tools and tasks. It reads top to bottom and does exactly what it says. When something breaks (rare now), you know exactly where to look.
We also stopped caring about editors. Mise doesn't know or care whether you use VS Code, RubyMine, Zed, or anything else. The dev environment just works.
What surprised us was how much the task runner grew after the migration. We kept adding to it: mise lint, mise test, mise pr:create (wraps gh for branch pushing and PR creation), git hooks in mise dev. Each addition was a few lines of TOML. Try doing that in a devcontainer setup.
And Ruby version, Node version, tool versions, dev tasks, CI — all live in mise.toml now. No more syncing .ruby-version files across three different places.
The Trade-off
There's one: initial machine setup. With Dev Containers, you installed Docker and VS Code and you were done. With mise, a new developer needs Docker, mise, and a couple of system libraries (libpq for PostgreSQL, gpg for signing). It's a few extra steps documented in our README.
A one-time 5-minute README walkthrough beats a recurring 5-minute container rebuild that you can't skip. We'd make this trade-off again every time.
If your team uses Dev Containers and it genuinely works, keep going. They solve real problems, especially for teams on GitHub Codespaces. But if you're losing time to Docker-in-Docker issues, slow rebuilds, and config sprawl, give mise an afternoon.
The best dev tooling is the kind you forget exists. We've been on mise for almost a year now. Nobody has filed a single issue about the dev environment. It just works.