dev.sh creates and reuses development containers without requiring a Compose
file in every project. Point it at a project directory to open an isolated
workspace, or point it at a catalog service or image to run a supporting
container.
The important idea is that containers are persistent:
- The first invocation creates and starts a managed container.
- Later invocations reuse that container and preserve its filesystem and tmux session.
- Changes to mounts, ports, environment variables, or other creation settings
require
--rebuild.
Managed containers use the devsh_ name prefix by default and join the
dev_default network, so they can reach one another by name or alias.
On the host:
- Bash
- Docker or Podman
jq- either mikefarah/yq v4+ or python-yq
Workspace images must also contain bash and tmux. The default
bravado/dev.sh:master image includes both, along with common development tools
and mise.
Clone this repository, add its script to your PATH, and use dev.sh to open
the repository's own development environment:
git clone https://github.com/bravado/dev.sh.git
cd dev.sh
mkdir -p "$HOME/.local/bin"
ln -s "$PWD/bin/dev.sh" "$HOME/.local/bin/dev.sh"
export PATH="$HOME/.local/bin:$PATH"
dev.sh .The final command uses dev.sh to create and enter a workspace for this same
repository. Its committed .dev.sh.yml supplies the development mounts and
tmux layout, making the repository itself a working example of the tool.
The script resolves the symlink before locating its catalog/ directory, so
catalogs continue to work when dev.sh is invoked from another directory. Add
$HOME/.local/bin to your shell configuration if it is not already on your
PATH.
Once installed, change to any project directory and run:
dev.sh .On the first run, dev.sh:
- creates a container named from the directory, such as
devsh_my-project; - bind-mounts the project at the same absolute path inside the container;
- uses that path as the container working directory;
- starts the container and creates the configured tmux layout; and
- attaches the terminal to the tmux session.
Detach from tmux with Ctrl-b d. Run dev.sh . again to return to the same
container and session.
Run a one-off command instead of attaching to tmux:
dev.sh . -- npm test
dev.sh . --user root -- apt-get updateThe -- separator is recommended because it makes the start of the command
unambiguous. --user applies to the command executed in the running container;
the container itself is created with the current host UID and GID.
Use a different image for this workspace:
dev.sh . --image registry.example.com/team/php-dev:8.4Use --name when two projects have the same directory name:
dev.sh . --name billing-apiAdd .dev.sh.yml to the project. A typical configuration looks like this:
image: registry.example.com/team/php-dev:8.4
ports:
- "8080:8080"
environment:
APP_ENV: development
volumes:
- ${HOME}/.ssh:/home/app/.ssh:ro
- app-cache:/home/app/.cache:chown
x-dev:
requires:
- docker
tmux:
session: app
windows:
- name: editor
panes:
- cmd: nvim
- split: h
size_percent: 35
cmd: bash
- name: server
panes:
- cmd: make servePreview container creation without changing anything:
dev.sh . --dry-runAfter editing creation settings, recreate the container:
dev.sh . --rebuild--rebuild removes the managed container and creates it again. Bind-mounted
files and named volumes remain, but changes made only in the old container's
writable layer are lost.
Workspace configuration starts with catalog/default.yml. The script then
looks for .dev.sh.yml files from the filesystem root down to the workspace:
/.dev.sh.yml
/home/.dev.sh.yml
/home/me/.dev.sh.yml
/home/me/project/.dev.sh.yml
Parent configuration is merged first and the closest file wins. Objects are
merged recursively; arrays are replaced, not appended. Environment variables
in string values are expanded, so ${HOME} in a volume declaration refers to
the host environment.
Project overrides are only merged when the default catalog is selected. A
named service catalog such as catalog/docker.yml is self-contained and
ignores .dev.sh.yml.
The default catalog also receives a persistent mise mount at
/home/app/.local/share/mise unless configuration already mounts that
destination.
The default image includes mise but does not install a global Node.js, Python,
or other language version. Commit a mise.toml in the project instead:
[tools]
node = "24"When a workspace containing mise.toml starts, dev.sh runs mise install in
the container. Runtime installations and caches persist through the host mount,
while each project selects its own versions.
For example:
dev.sh . -- node --version
dev.sh . -- npm installIf the first argument is not a directory or an existing managed container,
dev.sh treats it as an image/service target:
dev.sh dockerFor a simple target name, the script first looks for
catalog/<target>.yml. In this repository, docker selects
catalog/docker.yml, which starts a privileged Docker-in-Docker service and
prints its managed container name. A target tag overrides the catalog image's
tag:
dev.sh docker:27If no matching service catalog exists, the target is treated as an image and
catalog/default.yml supplies the remaining defaults:
dev.sh bravado/dev.sh:master -- bashThe image must stay running long enough for dev.sh to execute an interactive
command in it. Also note that default-catalog behavior, including its
x-dev.default_command, still applies to image targets without their own
catalog. For reusable services, a dedicated catalog file is usually clearer.
A configuration can start other catalog services before its own container:
x-dev:
requires:
- dockerRequired services are started in order. A service can expose environment values to its dependents:
x-dev:
environment:
DOCKER_HOST: tcp://{{name}}:2375
DOCKER_TLS_CERTDIR: ""Placeholders use the service context; {{name}} is the managed container name.
Values in the dependent container's regular environment: section take
precedence over injected values.
You can use either a managed container's name or one of its aliases:
dev.sh ps
dev.sh billing-api
dev.sh billing-api -- php -vIf the container is stopped, dev.sh starts it. A workspace target reopens its
tmux session; a service target runs its configured default command or prints its
container name.
Generated names replace /, :, and . with _. The default devsh_ prefix
can be changed for all newly created containers:
DEVSH_PREFIX=myteam_ dev.sh .Every managed container receives both its full container name and its short unprefixed name as network aliases.
dev.sh <folder> [options] [-- command...]
dev.sh <image-or-service> [options] [-- command...]
dev.sh <existing-name-or-alias> [--user USER] [-- command...]
Creation options:
| Option | Purpose |
|---|---|
--name NAME |
Set the managed container name. |
--network NETWORK |
Join a network other than dev_default. |
--alias ALIAS |
Add a network alias when creating the container. |
--image IMAGE |
Override the image for a workspace target only. |
--rebuild |
Recreate the container with current configuration. |
--dry-run |
Print the engine command without changing state. |
--tmux-reset |
Recreate the configured workspace tmux session. |
--key VALUE |
Supply a value to exposed-environment templates. |
Execution option:
| Option | Purpose |
|---|---|
--user USER |
Run the explicit command as this container user. |
Unknown --key value or --key=value pairs are stored as template parameters
and are available while rendering x-dev.environment. They are not passed
directly to Docker or Podman. Parameters are preserved by --rebuild, with
newly supplied values taking precedence.
dev.sh ps # list managed containers; * means running
dev.sh ls # alias for ps
dev.sh info <name-or-alias> # status, published ports, connection string
dev.sh logs <name-or-alias> -f # host-side container logs
dev.sh stop <name-or-alias>
dev.sh rm <name-or-alias-or-dir>
dev.sh rm -f <name-or-alias-or-dir>
dev.sh cd <name-or-alias> # print a running workspace's host pathrm refuses to remove a running container unless -f or --force is used.
Passing a directory to rm resolves the managed container associated with that
workspace.
inspect is intentionally different from info:
dev.sh inspect <name-or-alias> [docker-inspect-arguments...]It runs docker inspect inside the selected running container. This is
useful for a workspace with access to a Docker daemon; use info for details
about the selected managed container itself.
Save a container's current filesystem as an image:
dev.sh commit <name-or-alias> [tag]The resulting image is bravado/dev.sh:<tag>. If the tag is omitted, the full
managed container name is used.
dev.sh alias add <container-or-alias> <new-alias>
dev.sh alias rm <alias>Aliases are added to every network currently joined by the container and can be used both as CLI identifiers and as DNS names between managed containers.
Top-level keys mostly map to container creation options:
| Key | Meaning |
|---|---|
image |
Image used by a workspace or catalog service. |
command |
Container startup command; accepts a string or array. |
ports |
Port mappings such as 8080:80. |
environment |
Environment map or list. |
volumes |
Bind mounts and named volumes. |
working_dir |
Container working directory. Workspace mode overrides this with the workspace path. |
privileged |
Enable privileged mode with true. |
security_opt |
One or more security options. |
cap_add |
One or more Linux capabilities. |
devices |
One or more device mappings. |
sysctl |
Map, list, or single sysctl value. |
x-dev contains behavior implemented by this wrapper:
| Key | Meaning |
|---|---|
requires |
Catalog targets to start first. |
environment |
Environment values exposed to dependent containers. |
default_command |
Command executed after start when no command was supplied; string or array. |
connection.url |
Template shown by dev.sh info. |
user.additional_groups |
Supplemental group names or GIDs. |
user.auto_group_from_docker_sock |
Automatically add the mounted Docker socket's GID; defaults to true. |
tmux.session |
Session name; defaults to main. |
tmux.windows |
Workspace window and pane layout. |
Append :chown to a named volume to make app:app own the mounted content
when the container is first created:
volumes:
- app-cache:/home/app/.cache:chown:chown is rejected for host bind mounts because recursively changing host file
ownership would be unsafe. It runs only on creation, including --rebuild.
To access a host socket whose group differs between machines:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
x-dev:
user:
additional_groups:
- docker-sock
- sock:/path/to/another.sock
- 1234
- dockerdocker-sock (or docker.sock) resolves the host GID of
/var/run/docker.sock; sock:/path resolves the GID of another host path.
Numeric GIDs and group names are passed through unchanged. Mounting the Docker
socket enables its GID automatically unless disabled:
x-dev:
user:
auto_group_from_docker_sock: falsex-dev:
tmux:
session: workspace
windows:
- name: code
layout: even-horizontal
panes:
- cmd: nvim
- cmd: bash
- name: services
panes:
- cmd: make serve
- split: v
size_percent: 30
target: 0
cmd: make logsPane fields:
cmd: command sent to the pane; defaults tobash.split:h,horizontal,v, orvertical.size_percent: split size from1to99.target: zero-based index of the pane to split; defaults to the previous pane.
If any pane uses split, size_percent, or target, the window's layout is
not applied. Layout creation is idempotent for an existing session; use
--tmux-reset to discard and rebuild it.
- Configuration changes appear to do nothing: run
dev.sh . --rebuild. - A workspace opens the wrong same-named project: give one of them a unique
--name;dev.shreports ambiguous workspace matches when it can detect them. - A container name conflicts:
dev.shwill not replace an unmanaged Docker/Podman container. Rename or remove the conflicting container, or use--name. - Docker or Podman selection is wrong: set
CONTAINER_ENGINE=dockerorCONTAINER_ENGINE=podman. Docker is preferred when both are installed. - A command disappeared after
--rebuild: rebuild preserves catalog parameters, aliases, image, and network when possible, but it does not preserve changes made only in the container's writable layer.
The integration test creates and removes temporary containers, volumes, and images:
scripts/smoke.sh [image] [name] [alias] [volume-name]Its default image is bravado-dev-mise-test; build or provide a compatible image
before running it.