# Haskell Devcontainer

> Set up or detect a Haskell Dev Container using the pre-built ivelten/haskell-devcontainer Docker image. Covers detection of an existing devcontainer setup, creating .devcontainer/ files from scratch, VS Code extension list, direnv integration, and what the image does and does not ship. Use when bootstrapping a Haskell project in a container, checking whether a devcontainer is already configured, or setting up VS Code Dev Containers for Haskell.

- Skill: `ivelten/haskell-devcontainer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-devcontainer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-devcontainer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-devcontainer

---


# Haskell Dev Container

The pre-built image `ivelten/haskell-devcontainer` (Docker Hub) provides a complete, zero-setup Haskell environment: GHC 9.10.3, Cabal 3.12.1.0, HLS, Hoogle, fast-tags, cabal-gild, ormolu, direnv, and Claude Code CLI — all pre-installed.

## Detection — check before asking

When starting any Haskell project task, check whether the devcontainer is already configured:

```bash
grep -q "ivelten/haskell-devcontainer" .devcontainer/docker-compose.yml 2>/dev/null \
  && echo "devcontainer in use" || echo "not configured"
```

If `.devcontainer/docker-compose.yml` references the image → the environment is already set up. Acknowledge it and proceed. The toolchain versions match the defaults in `haskell-project-setup`; no local GHCup, `mise.toml` or `.tool-versions` is required for development (though a pin is still recommended for CI reproducibility).

## When the devcontainer is not configured

The environment choice itself belongs to the `haskell` router skill, which weighs the container against the native `mise` + GHCup path (`haskell-toolchain-mise`). Load this skill once the container has been chosen.

What the container buys: zero setup, a disposable environment, parity with a Linux CI, and Docker services (Postgres) alongside the app. What it costs: on macOS every container is a Linux VM, so CPU and bind-mount I/O are slower than native.

Once chosen, create the files below. The user then opens VS Code and selects **"Reopen in Container"**; the image is pulled from Docker Hub automatically.

## Files to create

**`.devcontainer/devcontainer.json`**

```json
{
  "name": "Haskell Development Environment",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/haskell",
  "customizations": {
    "vscode": {
      "extensions": [
        "haskell.haskell",
        "justusadam.language-haskell",
        "eriksik2.vscode-ghci",
        "mkhl.direnv",
        "usernamehw.errorlens",
        "editorconfig.editorconfig",
        "yzhang.markdown-all-in-one",
        "davidanson.vscode-markdownlint",
        "Anthropic.claude-code",
        "phoityne.phoityne-vscode"
      ],
      "settings": {
        "haskell.manageHLS": "GHCup",
        "haskell.ghcupExecutablePath": "/home/vscode/.ghcup/bin/ghcup",
        "haskell.formattingProvider": "ormolu",
        "editor.formatOnSave": true,
        "terminal.integrated.defaultProfile.linux": "zsh"
      }
    }
  },
  "remoteUser": "vscode",
  "postCreateCommand": "bash .devcontainer/post-create.sh",
  "postStartCommand": "bash .devcontainer/post-start.sh",
  "features": {
    "ghcr.io/devcontainers/features/common-utils:2": {
      "installZsh": true,
      "configureZshAsDefaultShell": true,
      "installOhMyZsh": false
    }
  }
}
```

**`.devcontainer/docker-compose.yml`**

```yaml
services:
  app:
    image: ivelten/haskell-devcontainer
    command: /bin/sh -c "while sleep 1000; do :; done"
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 2048
    volumes:
      - ..:/workspaces/haskell:cached
      - ${HOME}/.claude:/home/vscode/.claude:cached
      - ${HOME}/.claude.json:/home/vscode/.claude.json:cached
```

The `cap_drop`/`security_opt`/`pids_limit` trio keeps the container from acquiring privileges it never needs. Mounting `~/.claude` and `~/.claude.json` from the host makes the global skills, settings and the ormolu `PostToolUse` hook live inside the container.

**`.devcontainer/post-create.sh`** — runs once, when the container is created:

```bash
#!/bin/bash

# Install Claude Code if it is not already available
if command -v claude >/dev/null 2>&1; then
  echo "🤖 Claude Code already installed, skipping."
else
  echo "🤖 Installing Claude Code..."
  curl -fsSL https://claude.ai/install.sh | bash
  echo "🤖 Claude Code installation completed."
fi
```

**`.devcontainer/post-start.sh`** — runs on every container start:

```bash
#!/bin/bash

# Check if .envrc file exists and automatically allow it with direnv
if [ -f "/workspaces/haskell/.envrc" ]; then
  echo "📁 Found .envrc file, running direnv allow..."
  direnv allow /workspaces/haskell
  echo "📂 direnv allow completed."
else
  echo "📁 No .envrc file found, skipping direnv allow."
fi
```

**`.editorconfig`**

```ini
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.{hs,lhs}]
indent_style = space
indent_size = 2

[*.sh]
indent_style = space
indent_size = 2

[*.{yml,yaml,json}]
indent_style = space
indent_size = 2
```

## direnv inside the container

The `postStartCommand` runs `post-start.sh` on every container start, which calls `direnv allow` automatically if `.envrc` is present. The image has direnv hooked into both bash and zsh, so entering any directory with an `.envrc` activates the environment immediately.

Add a `.envrc` at the project root for per-project env vars (database URLs, API keys, feature flags):

```bash
# .envrc — committed to the repo
export DATABASE_URL="postgres://localhost/myapp_dev"
```

`.direnv/` (the cache directory) is gitignored.

## Toolchain pinning with the devcontainer

The image pins GHC 9.10.3 and Cabal 3.12.1.0. For CI consistency, still add:

```
# .tool-versions
ghc 9.10.3
cabal 3.12.1.0
```

and `with-compiler: ghc-9.10.3` in `cabal.project`. CI runs on a fresh GHCup install, not the image, so the pin keeps builds reproducible there — and it is the same pin the native path declares in `mise.toml`.

If the image is ever rebuilt on a newer GHC, the new version must appear in the HLS bindist list, or the container ships a broken language server. HLS targets specific GHC *patch* versions — see the compatibility rule in `haskell-toolchain-mise`. GHC 9.10.3 and HLS 2.14 are consistent today.

## What the image does not ship

Pre-installed: GHC 9.10.3, Cabal 3.12.1.0, Stack, GHCup, HLS, Hoogle (index pre-generated), ormolu, fast-tags, cabal-gild, direnv, zsh.

**Not** pre-installed, despite being assumed by other skills: `hlint` and `pre-commit` (`haskell-quality-gates`), `doctest` (`haskell-documentation`), `haskell-dap`/`ghci-dap`/`haskell-debug-adapter` (`haskell-debugging`), `ghc-prof-flamegraph`/`hp2pretty` (`haskell-benchmarking`). Install them with `cabal install` inside the container when the corresponding workflow starts.

## Related

- `haskell-project-setup` — project layout, `.cabal` template, language extensions.
- `haskell-toolchain-mise` — the native alternative to this container, and the HLS↔GHC compatibility rule.
- `haskell-debugging` — DAP setup (`phoityne-vscode` extension, already in the devcontainer's VS Code extensions list, handles this).

