I've recently discovered the wonders of development (dev) containers in my workflow. I work on a project with many different components, using various languages, and in some cases, different versions of those languages. These variations from component to component caused me to spend at least an hour or two every week fixing environment-related issues, not to mention the lengthy process of setting up my local environment when I first joined the project.
I will discuss the methods I used to build a fully containerized dev environment that is consistent, quick to rebuild, and speeds up development. Not only did this solve my problem, it is available for the entire team to use and is incredibly easy for new developers to start using.
Prerequisites
To build this environment, you'll need:
- Podman
- An integrated development environment (IDE) with support for dev containers. We'll use VS Code for this tutorial.
Before we dive into the tutorial, there are a few configuration steps in the IDE and Podman:
- Enable the compose extension in Podman.
- Install the dev containers extension.
- Configure the dev containers extension. In the extension settings, set the following values:
- "Copy Git Config":
Enabled
- "Docker Compose Path":
podman compose
- "Docker Path":
podman
- "Docker Socket Path":
unix:///run/user/1000/podman/podman.sock
- Note: This value is usually correct. However, you can run the following command to confirm your path:
$ podman info --format "{{.Host.RemoteSocket.Path}}"
- Note: This value is usually correct. However, you can run the following command to confirm your path:
- "Copy Git Config":
Demo repository
This tutorial uses an accompanying demo repository to explain the concepts. The repo follows this hypothetical setup:
- Two Quarkus microservices that communicate with each other using Kafka.
- One Python API that provides external access to the microservices.
To support these services, we also need:
- PostgreSQL database
- Kafka broker
- ZooKeeper (required by Kafka)
You can apply the same setup to any combination of technologies or languages. The key takeaway is that these components rely on each other and often need extra infrastructure—like databases and messaging systems—to run properly.
Creating the dev environment
The configuration for dev containers is often handled on a per-repository basis. For instance, when you open a repo in VS Code, you might be prompted to create a new dev container config for that repo. While there’s nothing inherently wrong with that approach, it may not be ideal when working with multiple repos. This tutorial shows you how to create a single development repository that everyone on your team can use for an easily repeatable local dev environment.
This process consists of six steps:
- Start a new repository.
- Write your Containerfile(s).
- Clone your repos.
- Write a Compose file.
- Write the dev container configs.
- Define environment variables.
Step 1: Start a new repository
To begin, create a new repository in your Git platform of choice. This repository will be the gateway to developing the set of components you choose, so name it accordingly. Personally, I use a <name of product>-development
format.
Next clone the repo and open in your IDE. Then create the following directories:
./Containerfiles
: Stores any Containerfiles you may need../.devcontainer
: Stores all dev container configuration files.
Step 2: Write your Containerfile(s)
You can use default dev container images if you’d like, but most users will need to add extra packages or tools. There are many dev container images available from various sources, including Microsoft dev container base images and dev container templates.
For this tutorial, I’ll use:
mcr.microsoft.com/devcontainers/python:3.11
for the API.mcr.microsoft.com/devcontainers/java:17
for the Quarkus microservices.
In the ./Containerfiles
directory, create your Containerfiles. You can name them however you like (e.g., Containerfile.python311
and Containerfile.java17
).
Here’s an example:
FROM mcr.microsoft.com/devcontainers/python:3.11
# Set env vars you want for ALL python environments
ENV HOME="/root"
ENV PATH="/opt/venv/bin:$PATH"
# Copy in anything you may need.
# In this example we have a ./Containerfiles/scripts dir to hold any scripts needed after the containers are created.
COPY --chmod=0755 Containerfiles/scripts /tmp/scripts
# Install any specific packages you may need.
# Here we will install a few helpful tools and establish a Python virtual environment.
RUN apt update -y \
&& apt -y install --no-install-recommends \
kafkacat \
curl \
maven \
postgresql-client
RUN mkdir -p /opt/venv && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip && \
/opt/venv/bin/pip install pre-commit
CMD ["sleep", "infinity"]
Step 3: Clone your repos
This step might sound simple, but it can be tricky if you’re building something for a team. What I mean by "clone your repos" is make sure users clone their forks into a specific directory. You can enforce this with a script.
For example, the script could:
- Check if
./repos
exists.- If it does, ask the user if they want to delete it.
- Or else, create the directory.
- Ask for the user's Git username.
- Use the username and a pre-defined YAML list of repos (
./repos.yaml
in the demo) to clone all of their forks to the./repos
directory.
I would recommend writing your own script to fit your needs. Modifying the one I provide in the demo repo may be a good start. Whatever solution you choose, be sure to add your chosen directory to the repo’s .gitignore
.
Step 4: Write a Compose file
The Compose file ties everything together. You have a lot of freedom here to define your environment exactly how you need it. You’ll need to define all your components in this file, including supporting infrastructure.
First, add the components you plan to develop. For example, use the Containerfiles written earlier to build the image. Notice the volumes—one mounts the user's local SSH config (for Git authentication), and the other mounts the directory holding the repos we cloned earlier:
services:
microservice-1:
container_name: microservice-1
image: "demo-dev-java:17"
volumes:
- ~/.ssh:/root/.ssh:cached,z
- ../repos:/root/repos:cached,z
networks:
- demo-network
Add any supporting infrastructure. For example, here is the Kafka broker service used in the demo:
kafka:
hostname: kafka
image: docker.io/wurstmeister/kafka:latest
ports:
- "9092:9092"
environment:
- KAFKA_BROKER_ID=1
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
- ALLOW_PLAINTEXT_LISTENER=yes
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT
- KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
- KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT
- KAFKA_CREATE_TOPICS=demo-topic-one:1:1,demo-topic-two:1:1
volumes:
- type: tmpfs
target: /kafka
depends_on:
- zookeeper
networks:
- demo-network
Finally, add the shared network as follows:
networks:
demo-network:
driver: bridge
Add or configure anything else you may need in your environment. Save this file wherever you prefer. I keep it at ./compose.yaml
.
Step 5: Write the dev container configs
These configuration files define how your IDE will run within each dev container. The following code snippet is an example devcontainer.json
you can tweak to your needs. This example demonstrates a few possibilities, but there are many configuration options to choose from.
{
"name": "microservice-1",
"dockerComposeFile": [
"../compose.yaml"
],
"service": "microservice-1",
"workspaceFolder": "/root/repos/microservice-1",
"updateRemoteUserUID": true,
"remoteUser": "root",
"onCreateCommand": "/bin/bash /tmp/scripts/on-create.sh",
"customizations": {
"vscode": {
"settings": {
"chat.commandCenter.enabled": false,
"editor.renderWhitespace": "all"
},
"extensions": [
"redhat.java",
"redhat.vscode-quarkus",
"redhat.vscode-yaml",
"redhat.vscode-xml"
]
}
}
}
Organize your .devcontainer
folder so each component has its own directory as follows:
.devcontainer
├── api
│ └── devcontainer.json
├── microservice-1
│ └── devcontainer.json
├── microservice-2
│ └── devcontainer.json
Here is an explanation of the example config:
name
: Name of the dev container.dockerComposeFile
: References your Compose file.service
: The name of the service defined in the Compose file.workspaceFolder
: The folder in the container opened by your IDE (here it points to the./repos
directory).updateRemoteUserUID
: Matches the UID/GID of the container user to your local user (prevents permissions issues).remoteUser
: Which user to run as in the container.onCreateCommand
: Command to run after the container is created.customizations
: Defines IDE settings and extensions.
Step 6: Define environment variables
If you need environment variables, you can either add them directly in the Compose file or define them in a file and reference that in the Compose file. It’s often helpful to have a single common.env
file for variables used across components and individual *.env
files for variables specific to each component as follows:
.devcontainer
├── api
│ └── devcontainer.json
│ └── var.env
├── microservice-1
│ └── devcontainer.json
│ └── var.env
├── microservice-2
│ └── devcontainer.json
│ └── var.env
└── common.env
The following is in the Compose file:
services:
microservice-1:
container_name: microservice-1
image: "demo-dev-java:17"
env_file:
- ./common.env
- ./microservice-1/var.env
volumes:
- ~/.ssh:/root/.ssh:cached,z
- ../repos:/root/repos:cached,z
networks:
- demo-network
Set up the dev environment
To set up the environment:
- Build the images using the Containerfiles you created, making sure to match the Compose file’s image name:
podman build -f ./Containerfiles/Containerfile.java17 -t demo-dev-java:17
.
Note:
To streamline this process, you could build the images in CI and publish them to an image registry or add these commands to a Makefile.
- Make sure your repos are cloned.
Open the environment in a container
Next open the environment in a container:
- Open the new
*-development
(or whatever you named it) repo in VS Code, if you haven't already. - Open the command palette by entering: CTRL + SHIFT + P.
- Search for and select: Dev Containers: Reopen in Container.
- Select the name of the component you want to start developing.
After you select the component, the window will appear blank and show a status bar in the bottom-right corner. You can decide if you want to watch the startup logs. It may take a few minutes, depending on what kind of scripts you have set up to run on start up. The first time you do this, it may take additional time because you also need to create all of the containers.
Switch to a different component
To switch to an alternate component:
- Open the command palette by entering: CTRL + SHIFT + P.
- Search for and select: Dev Containers: Switch Container.
- Select the name of the component you want to switch to.
Rebuild the containers
When you break something, just rebuild the environment as follows:
- Open the command palette by entering: CTRL + SHIFT + P.
- Search for and select: Dev Containers: Rebuild Container.
This will destroy the environment and rebuild it. You will not lose progress on any work as it is stored locally and mounted to the containers.
Next steps
This tutorial only scratches the surface of what’s possible. Check out the official documentation for more ideas.