Setting Up a VSCode Dev Container for a Multi-Service Architecture

Secure software development isn't just something we help our customers achieve—it is a core practice built into how we write, containerize, and ship software every day. Supply chain attacks targeting registries like npm and PyPI have made developer environment isolation a critical first line of defense.

In this developer-to-developer guide, our senior developer walks through how to set up an isolated, single-window VSCode Dev Container architecture for multi-service applications without sacrificing local development productivity.


Background

Supply chain attacks targeting package registries like npm and PyPI are on the rise. Malicious dependencies installed directly onto your workstation can, for example, compromise personal files. Because of this, it is critical for developers to secure their local development environments. An effective way to do that is through isolation.

By shifting all dependency installations and code execution into a containerized environment, you create a strict blast radius. If a rogue script executes, it is trapped inside a container, unable to touch your host machine.

One caveat to keep in mind is the use of AI coding agents. While containers provide good isolation, allowing agents to run rampant can punch holes in the provided security. For example, prompt injection can lead to the execution of arbitrary commands.

While not a perfect solution to all your security woes, Visual Studio Code (VSCode) Dev Containers are a great way to bring isolation into your daily workflow. As a nice secondary benefit, they provide your entire team with a unified, reproducible development environment and reduce onboarding time for new employees.

However, Dev Containers have one glaring limitation: a single VSCode window can only connect to one container. If you are building a system consisting of multiple services, this would mean juggling multiple VSCode windows or constantly switching container contexts just to edit different services.

In this post, we will solve that limitation. We will build a secure, isolated, single-window development environment for a microservices stack consisting of two Python Flask backends and a Vue User Interface.

Project Structure

To achieve a single-window workflow without compromising isolation, we will create the following directory structure:

.
├── .devcontainer/
│   ├── devcontainer.json
│   ├── docker-compose.yml
│   └── Dockerfile
├── service-a/ (Flask)
│   ├── app.py
│   ├── Dockerfile
│   └── requirements.txt
├── service-b/ (Flask)
│   ├── app.py
│   ├── Dockerfile
│   └── requirements.txt
├── ui-app/ (Vue)
│   ├── Dockerfile
│   ├── package.json
│   ├── vite.config.ts
│   └── ...
└── docker-compose.yml

The above structure contains two distinct configuration layers under the same root directory:

  1. The IDE Layer (.devcontainer/): Our Dev Container configuration.

  2. The Execution Layer (docker-compose.yml): The actual microservice containers that run and serve our code to the host machine’s browser.

You can create most files present in that structure by running the following commands in your chosen root directory:

mkdir .devcontainer service-a service-b ui-app
touch .devcontainer/devcontainer.json \
      .devcontainer/docker-compose.yml \
      .devcontainer/Dockerfile
touch docker-compose.yml
touch service-a/app.py \
      service-a/Dockerfile \
      service-a/requirements.txt
touch service-b/app.py \
      service-b/Dockerfile \
      service-b/requirements.txt

Then open the root directory in VSCode by using:

code .

Then install the Dev Containers extension, created by Microsoft, to VSCode. You can open the extensions tab by pressing Ctrl + Shift + X.

With that out of the way, let’s get building!

Scaffolding the Dev Container

First, we need to define the workspace container where we will write all our code. Populate the files inside the .devcontainer/ directory with the following contents:

.devcontainer/devcontainer.json

{
    "name": "My Project",
    "dockerComposeFile": ["docker-compose.yml"],
    "service": "my-project-dev-container",
    "workspaceFolder": "/workspace",
    "remoteUser": "node",
    "customizations": {
      "vscode": {
        "extensions": [
          "ms-python.python",
          "Vue.volar"
        ]
      }
    }
}

You can use the customizations section to add in your favorite VSCode extensions and to define workspace-level code linting rules, etc.

.devcontainer/docker-compose.yml

services:
  my-project-dev-container:
    build:
      context: .
      dockerfile: Dockerfile
      additional_contexts:
        # Allows the Dev Container's Dockerfile to access service dependencies
        service_a: ../service-a/
        service_b: ../service-b/
        ui_app: ../ui-app/
    # Keeps the container alive so VSCode stays connected
    command: sleep infinity
    volumes:
      # Mount the .devcontainer configuration so it can be
      # modified from within the dev container itself
      - ../.devcontainer:/workspace/.devcontainer
      # Mount service code, so we can edit it and changes
      # make their way onto the host machine
      - ../service-a:/workspace/service-a
      - ../service-b:/workspace/service-b
      - ../ui-app:/workspace/ui-app

.devcontainer/Dockerfile

FROM node:26-trixie

WORKDIR /workspace

# Install Python and venv
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        python3-pip \
        python3-venv \
    && python3 -m venv /home/node/.venv

# Pre-create directories and make our non-root user their owner
RUN mkdir -p \
        /workspace/.devcontainer \
        /home/node/.vscode-server \
        /workspace/service-a \
        /workspace/service-b \
        /workspace/ui-app \
        /workspace/ui-app/node_modules \
    && chown -R node:node \
        /workspace/.devcontainer \
        /home/node/.vscode-server \
        /home/node/.venv \
        /workspace/service-a \
        /workspace/service-b \
        /workspace/ui-app

# Switch to a non-root user
USER node

# Make container terminal use our Python virtual environment by default
ENV PATH="/home/node/.venv/bin:${PATH}"

# Install Backend project dependencies
COPY --from=service_a requirements.txt /workspace/service-a/requirements.txt
COPY --from=service_b requirements.txt /workspace/service-b/requirements.txt
RUN pip install \
    -r /workspace/service-a/requirements.txt \
    -r /workspace/service-b/requirements.txt

With that done, we have created a very basic dev container. You can press Ctrl + Shift + P and select Dev Containers: Reopen in Container to build and open it.

Scaffolding the Services

With the IDE container running, we can create the actual microservices.

Python Backends

Starting off with the Python backend, for both service-a and service-b, add Flask to requirements.txt:

Flask==3.1.3

If you want VSCode’s Python IntelliSense to now pick up on Flask being added, rebuild the Dev Container (Ctrl + Shift + P > Dev Containers: Rebuild Container).

Then, create a simple API endpoint in app.py for both services:

from flask import Flask

app = Flask(__name__)

@app.route("/service-a") # TODO: Change to /service-b for the second service
def home():
    return "Hello, from Service A!" # Same here


if __name__ == "__main__":
    # TODO: Use port 5000 for Service A, 5005 for Service B
    app.run(host="0.0.0.0", port=5000, debug=True) # debug=True makes the Flask app auto-reload on code changes

Vue Frontend

Then we shall create the UI application, mainly following the Vue quick start guide (https://vuejs.org/guide/quick-start.html), with some additions.

Open the integrated terminal inside your Dev Container and scaffold the Vue application:

npm create vue@latest # Use ui-app as the project name!

Before installing the frontend dependencies, we need to make an adjustment to our .devcontainer/docker-compose.yml file. Add this new volume:

- /workspace/ui-app/node_modules

This volume stops our local ui-app mount from overwriting the /node_modules within the Dev Container. It also ensures that dependencies don’t get wiped if the container is stopped, and the files won’t end up on your host machine.

Rebuild the Dev Container (Ctrl + Shift + P > Dev Containers: Rebuild Container).

Once reconnected, navigate into the ui-app/ directory and create an .npmrc file:

ignore-scripts=true

It prevents arbitrary scripts from automatically executing when you install packages.

Now, install the dependencies into the Dev Container:

cd ui-app
npm install

Finally, configure Vite to proxy API requests to our backend services. Update ui-app/vite.config.ts to include the following:

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
  ],
  server: {
    port: 3000,
    proxy: {
      '/service-a': {
        target: 'http://service-a:5000',
        changeOrigin: true,
      },
      '/service-b': {
        target: 'http://service-b:5005',
        changeOrigin: true,
      }
    }
  },
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

In ui-app/package.json, modify the dev script to have the following value:

"dev": "vite --host",

This way we can access the UI from our host machine’s browser.

Finally, add the UI dependency installation instructions to .devcontainer/Dockerfile. Then rebuild.

ENV PATH="/home/node/.venv/bin:${PATH}"

# Install UI dependencies (ADD THIS)
COPY --from=ui_app package*.json .npmrc /workspace/ui-app/
RUN cd /workspace/ui-app && npm ci

# Install Backend project dependencies
COPY --from=service_a requirements.txt /workspace/service-a/requirements.txt

Containerizing the Services

We now have our workspace and code ready. The final step is containerizing the individual services.

Use the following Dockerfile for both Python services:

FROM python:3.14-slim

# Create a non-root user
RUN useradd --create-home developer

WORKDIR /app

# Install Python dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt

# Change ownership of the /app directory
RUN chown -R developer:developer /app

# Switch to the non-root user
USER developer

COPY app.py /app/

# Start the Flask app
CMD ["python", "app.py"]

And this Dockerfile for the UI:

FROM node:26-trixie

WORKDIR /app

RUN chown -R node:node /app

# Copy dependencies
COPY package*.json .npmrc ./

# Switch to a non-root user
USER node

# Install dependencies
RUN npm ci

COPY --chown=node:node . .

# Start the UI in development mode
CMD ["npm", "run", "dev"]

Finally, on your host machine, add the following to the root directory’s docker-compose.yml:

services:
  service-a:
    build:
      context: ./service-a # For auto-reloads
    volumes:
      - ./service-a:/app
    ports:
      - 5000:5000
  service-b:
    build:
      context: ./service-b
    volumes:
      - ./service-b:/app
    ports:
      - 5005:5005
  ui-app:
    build:
      context: ./ui-app
    volumes:
      - ./ui-app:/app
      - /app/node_modules # Prevent overwrites
    ports:
      - 3000:3000

To run the microservice stack, simply open a terminal on your host machine, navigate to the project root, and run:

docker compose up --build

The UI will be available on localhost port 3000. The backend endpoints are accessed based on the proxy rules we created.

You now have a fully containerized, secure development environment. Code edits made inside your Dev Container window are instantly propagated via volumes to the running microservices. Each component auto-reloads on save, your host OS remains completely insulated from malicious dependencies, and any new developer can spin up the entire architecture in no time!

Tommi Järvenpää

Tommi is Senior Software Engineer at Test of Things focusing on back-end development and development infrastructure.

Next
Next

Confidentiality and Integrity Under the CRA: One Mechanism, Two Obligations