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/
Comments
This setup fairly advanced!
- Using custom domain locally - not localhost:port!
- Using nginx - production like
- 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"
}
}
}
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
Dockerfile
FROM isuperman/elm-devcontainer-foundation:0.1.7
# Project specifics could be added here
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 / /;
}
}
}
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)