DEV Community

Theodor Heiselberg
Theodor Heiselberg

Posted on

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

The Elm development environment

I prefer as ultra stable, shareable and reproducible an environment as possible so my preferred approach is using a devcontainer.

Until recently I saw the .devcontainer as a intrinsically part of my project only containing the one true development container setup. Devs could use dotfiles to bring custom configs and setup that way.

However my perspective has changed recently and now I just add a folder with my own name to the .devcontainer folder and leave it up to each dev how they prefer to roll.

Here I'll share my current favorite setup when developing for Elm in the previously described tech stack. And it dose involve quite a few moving parts - so hold on!

Building the base container

The project for the base contaner - tailored for my needs - can be found here: elm-development-environment-builder
Don't get fooled by the name - it's tailored for my needs only - but do get inspired. :)

The devcontainer setup

Folder structure

I'm considering adding theodor to .gitignore but I'm undecided right now.

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/
Enter fullscreen mode Exit fullscreen mode

Comments

This setup fairly advanced!

  1. Using custom domain locally - not localhost:port!
  2. Using nginx - production like
  3. Setting environment variables using nginx and site-config-loader!

devcontainer.json

{
  "name": "Dotnet 9/10 and Elm Dev Container (Debian + Compose)",
  // --- Use Docker Compose configuration ---
  // Point to your docker-compose file (relative to .devcontainer folder)
  "dockerComposeFile": "docker-compose.yml",
  // The name of the service defined in docker-compose.yml that VS Code should attach to
  "service": "dev",
  // The path inside the container where your project files are mounted by docker-compose.yml
  "workspaceFolder": "/workspace",
  "mounts": [
    "source=ktk-elm-devcontainer,target=/home/container-user/.elm,type=volume"
  ],
  "customizations": {
    "vscode": {
      "settings": {
        // Set bash as the default terminal (installed in Dockerfile)
        "terminal.integrated.defaultProfile.linux": "zsh"
        // Optional Neovim integration path:
        // "vscode-neovim.neovimExecutablePaths.linux": "/usr/bin/nvim"
      },
      "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"
      ]
    }
  },
  // The user VS Code should run as inside the service container.
  // Should match the user the service runs as (container-user in this case).
  "remoteUser": "container-user",
  "portsAttributes": {
    "3033": {
      "label": "Elm"
    },
    "5130": {
      "label": "Backend"
    },
    "5140": {
      "label": "Gateway"
    },
    "8314": {
      "label": "Nginx"
    },
    "8376": {
      "label": "Squidex"
    },
    "9876": {
      "label": "Elm"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

services:
  # Define our main development service, let's call it 'dev'
  dev:
    # Instructions on how to build the image for this service
    build:
      # Use the current directory (.devcontainer) as the build context
      context: .
      # Specify the Dockerfile within the build context
      dockerfile: Dockerfile

    # Define volumes to mount
    volumes:
      # Mount the entire project directory (one level up from .devcontainer)
      # into the /workspace directory inside the container.
      # 'cached' optimizes mount performance on macOS/Windows.
      - ../..:/workspace:cached
      - ktk-elm-devcontainer:/home/container-user/.elm
      # --- Alternative for Neovim config ---
      # Instead of COPY in Dockerfile, you could mount it here:
      # Ensure the source path '../.nvim' exists in your project root.
      # - ../.nvim:/root/.config/nvim:cached
         # --- ADD THIS LINE ---
      # Mount the project's nvim config into the root user's config dir
      # Source: ./.config/nvim (relative to this docker-compose.yml file)
      # Target: /root/.config/nvim (where nvim looks when run as root)
      # - ./.config/nvim:/root/.config/nvim:cached
    # Command to run when the container starts.
    # 'sleep infinity' keeps the container running indefinitely so VS Code can attach.
    command: sleep infinity
    networks:
      - internal

    # Environment variables can be defined here if needed
    # environment:
    #   - DATABASE_URL=...

    # The service will run as 'root' because that's the last USER in the Dockerfile.
    # You could explicitly set 'user: root' here if desired.
  nginx:
    image: nginx:alpine
    container_name: ktk_nginx
    ports:
     - "80:80"
     - "443:443"
     - "8314:80"
    networks:
      - internal
    volumes:
      # - ../..:/workspace
      - ./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:
  ktk-elm-devcontainer:
  ktk_squidex_assets:
  ktk_mongo_data:

networks:
  internal:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Dockerfile

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

Here is the config for running extremely production like!
I'm even using a custom domain for local development.
This setup make it possible to test routing, a tarpit and the likes.
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;

    # Define resolver to handle host.docker.internal
    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;
        }
        # Proxy frontend requests to Elm dev server
        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 '</head>' '<meta name="environment-name" content="local" /></head>';
            sub_filter_once on;
            # Only apply sub_filter to index.html
            # sub_filter_types text/html;
        }

        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;

            # WebSocket support
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';

            # Authentication flows
            proxy_cookie_path / /;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

Here we see an example of how one could use a devcontainer to set up a development environment. Using this approach has definitely helped me a lot. This is very production-like, and by using most of these settings for a local docker-compose.yml, I am extremely confident when pushing my containers to production. To emphasize, I rarely have bugs in production due to this setup.

Next up we will focus on how to setup vite-plugin-elm-watch which will require some tingling in order to run inside a devcontainer.

Top comments (0)