(2)Creating the Pinnacle of Niche Software: The devcontainer

Published: (December 22, 2025 at 04:31 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

Overview

I prefer an ultra‑stable, shareable, and reproducible environment, so my go‑to solution is a devcontainer — see the official docs.

Until recently I treated the .devcontainer folder as an intrinsic part of the project, containing the single “true” development container setup. Developers could bring their own custom configs via dotfiles.

My perspective has shifted. Now I add a folder with my own name inside .devcontainer and let each developer decide how they want to extend it.

Below is my current favorite setup for Elm development in the tech stack described in my earlier post. It involves a few moving parts, so hold on!

Building the Base Container

The repository that builds the base container (tailored to my needs) is:

Note: The name is specific to my workflow, but feel free to get inspired.

The devcontainer Setup

Folder Structure

project-root/
├── .devcontainer/
│   ├── vanilla/                 # Basic example
│   └── theodor/                 # Personal environment config
│       ├── ssl/
│       │   ├── squidex.crt
│       │   └── squidex.key
│       ├── devcontainer.json
│       ├── docker-compose.yml
│       ├── Dockerfile
│       ├── Dockerfile.omnia
│       └── nginx.conf
├── backend/
├── backup/
├── build/
├── Documentation/
├── dotnet/
└── elm/

I’m considering adding theodor to .gitignore but haven’t decided yet.

Comments

  • This setup is fairly advanced!
  • Uses a custom domain locally – not localhost:port.
  • Runs nginx in a production‑like configuration.
  • Sets environment variables via nginx and the site‑config‑loader tool.

devcontainer.json

{
  "name": "Dotnet 9/10 and Elm Dev Container (Debian + Compose)",
  "dockerComposeFile": "docker-compose.yml",
  "service": "dev",
  "workspaceFolder": "/workspace",
  "mounts": [
    "source=ktk-elm-devcontainer,target=/home/container-user/.elm,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        "terminal.integrated.defaultProfile.linux": "zsh"
      },
      "extensions": [
        "ms-vscode-remote.remote-containers",
        "Elmtooling.elm-ls-vscode",
        "ms-dotnettools.csharp",
        "william-voyek.vscode-nginx",
        "vscodevim.vim",
        "ms-dotnettools.csdevkit",
        "EditorConfig.EditorConfig",
        "humao.rest-client",
        "esbenp.prettier-vscode",
        "DotJoshJohnson.xml",
        "streetsidesoftware.code-spell-checker",
        "streetsidesoftware.code-spell-checker-danish",
        "bradlc.vscode-tailwindcss",
        "kamikillerto.vscode-colorize",
        "Ionide.Ionide-fsharp",
        "ms-azuretools.vscode-containers",
        "jebbs.plantuml",
        "task.vscode-task",
        "ecmel.vscode-html-css"
      ]
    }
  },
  "remoteUser": "container-user",
  "portsAttributes": {
    "3033": { "label": "Elm" },
    "5130": { "label": "Backend" },
    "5140": { "label": "Gateway" },
    "8314": { "label": "Nginx" },
    "8376": { "label": "Squidex" },
    "9876": { "label": "Elm" }
  }
}

docker-compose.yml

services:
  # Main development service
  dev:
    # Build the image for this service
    build:
      context: .
      dockerfile: Dockerfile

    # Volumes to mount
    volumes:
      - ../..:/workspace:cached
      - ktk-elm-devcontainer:/home/container-user/.elm

      # --- Alternative for Neovim config ---
      # - ../.nvim:/root/.config/nvim:cached

      # --- ADD THIS LINE ---
      # - ./.config/nvim:/root/.config/nvim:cached

    # command: ["sleep", "infinity"]

Keeping the Container Alive

command: sleep infinity
networks:
  - internal

Services

nginx:
  image: nginx:alpine
  container_name: ktk_nginx
  ports:
    - "80:80"
    - "443:443"
    - "8314:80"
  networks:
    - internal
  volumes:
    - ./nginx.conf:/etc/nginx/nginx.conf:ro
    - ./ssl:/etc/nginx/ssl:ro
  extra_hosts:
    - "host.docker.internal:host-gateway"
  depends_on:
    - dev
    - squidex

mongo:
  image: "mongo:6"
  volumes:
    - ktk_mongo_data:/data/db
  networks:
    - internal
  restart: unless-stopped

squidex:
  image: "squidex/squidex:7"
  ports:
    - "8376:5000"
  environment:
    - URLS__BASEURL=https://squidex.ktk.dk
    - IDENTITY__ALLOWHTTPSCHEME=false
    - EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo
    - STORE__MONGODB__CONFIGURATION=mongodb://mongo
    - IDENTITY__ADMINEMAIL=sukkerfrit@gmail.com
    - IDENTITY__ADMINPASSWORD=0hSoS3cret!
    - ASPNETCORE_URLS=http://+:5000
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:5000/healthz"]
    start_period: 60s
  depends_on:
    - mongo
  volumes:
    - ktk_squidex_assets:/app/Assets
  networks:
    - internal
  restart: unless-stopped

Volumes

volumes:
  ktk-elm-devcontainer:
  ktk_squidex_assets:
  ktk_mongo_data:

Networks

networks:
  internal:
    driver: bridge

Dockerfile

FROM isuperman/elm-devcontainer-foundation:0.1.7
# Project specifics could be added here

Production‑like Local Development

This configuration lets you test routing, a tarpit, and more. A custom domain is used for local development, making the environment feel very close to production.

nginx.conf

worker_processes 1;
events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    resolver 127.0.0.11;

    server {
        listen 80;
        server_name localhost ktk.dk;

        location /api/ {
            set $backend_upstream host.docker.internal:5130;
            rewrite ^/api/(.*)$ /$1 break;
            proxy_pass http://$backend_upstream;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
        }

        location /cms/ {
            set $backend_upstream host.docker.internal:5140;
            rewrite ^/cms/(.*)$ /$1 break;
            proxy_pass http://$backend_upstream;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
        }

        location / {
            set $frontend_upstream host.docker.internal:3033;
            proxy_pass http://$frontend_upstream;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            sub_filter '' '';
            sub_filter_once on;
        }

        location = /robots.txt {
            set $frontend_upstream host.docker.internal:3033;
            proxy_pass http://$frontend_upstream/robots.txt;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
        }

        location = /humans.txt {
            set $frontend_upstream host.docker.internal:3033;
            proxy_pass http://$frontend_upstream/humans.txt;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
        }
    }

    server {
        listen 80;
        server_name squidex.ktk.dk;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name squidex.ktk.dk www.squidex.ktk.dk;

        ssl_certificate /etc/nginx/ssl/squidex.crt;
        ssl_certificate_key /etc/nginx/ssl/squidex.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        location / {
            proxy_pass http://squidex:5000;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_cookie_path / /;
        }
    }
}

Conclusion

This example shows how a devcontainer can be used to create a development environment that mirrors production. By using these settings in a local docker‑compose.yml, I’m extremely confident when pushing containers to production—bugs in production are now a rarity.

Next up we will focus on how to set up vite-plugin-elm-watch, which will…

Back to Blog

Related posts

Read more »