feat: add Nix flake dev environment with Erlang 29.0.3 + Elixir 1.20.2

- Add flake.nix + flake.lock pinning nixpkgs-unstable
- Add nix/shell.nix with PostgreSQL, Rust, Elixir LSP, pre-commit hooks
- Add shell.nix flake-compat shim for non-flake nix-shell users
- Bump Dockerfile to Elixir 1.20.2 / OTP 29.0.3
- Add nix.md with setup and troubleshooting docs
- Gitignore Nix runtime state and generated pre-commit config

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Graham McInitre 2026-07-15 12:57:10 -05:00
parent aa6c885683
commit a8ecee32b0
8 changed files with 648 additions and 3 deletions

10
.gitignore vendored
View file

@ -56,6 +56,16 @@ microwaveprop-*.tar
# Git worktrees
/.worktrees/
# Nix dev shell runtime state (local to each machine)
.nix-postgres/
.nix-mix/
.nix-hex/
.nix-services-started
.direnv/
# Generated by pre-commit-hooks.nix (configuration lives in nix/shell.nix)
.pre-commit-config.yaml
# OS files
.DS_Store
Thumbs.db

View file

@ -1,2 +1,2 @@
erlang 29.0.2
erlang 29.0.3
elixir 1.20.2-otp-29

View file

@ -11,8 +11,8 @@
# - codeberg.org/gmcintire/prop-base - prebuilt runtime base (see Dockerfile.base)
# - Ex: docker.io/hexpm/elixir:1.19.5-erlang-28.4.1-debian-trixie-20260316-slim
#
ARG ELIXIR_VERSION=1.20.1
ARG OTP_VERSION=29.0.2
ARG ELIXIR_VERSION=1.20.2
ARG OTP_VERSION=29.0.3
ARG DEBIAN_VERSION=trixie-20260518-slim
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"

124
flake.lock generated Normal file
View file

@ -0,0 +1,124 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784007870,
"narHash": "sha256-djcLt/JJphyNt4eDY9XTly+/WbCK5lqWq9lSgCmJkkQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18b9261cb3294b6d2a06d03f96872827b8fe2698",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769939035,
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

69
flake.nix Normal file
View file

@ -0,0 +1,69 @@
{
description = "Prop Microwave propagation prediction for NTMS";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
systems.url = "github:nix-systems/default";
pre-commit-hooks = {
url = "github:cachix/pre-commit-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{
self,
nixpkgs,
flake-parts,
systems,
pre-commit-hooks,
}:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = builtins.filter (s: s != "x86_64-darwin") (import systems);
perSystem =
{
config,
self',
inputs',
pkgs,
system,
...
}:
{
_module.args.pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
overlays = [
# Pin Erlang 29.0.3 and Elixir 1.20.2 (matches .tool-versions)
(final: prev: {
erlang = prev.beam.interpreters.erlang_29;
elixir = prev.beam.packages.erlang_29.elixir_1_20;
beamPackages = prev.beam.packages.erlang_29.extend (
self: super: {
elixir = super.elixir_1_20;
}
);
})
];
};
# Development shell with PostgreSQL, Rust, Elixir LSP
devShells.default = pkgs.callPackage ./nix/shell.nix {
pre-commit-hooks = inputs.pre-commit-hooks.lib.${system};
};
# Nix formatter for flake files
formatter = pkgs.nixfmt;
};
};
}

155
nix.md Normal file
View file

@ -0,0 +1,155 @@
# Nix + direnv Setup
This project uses [Nix flakes](https://nixos.wiki/wiki/Flakes) to provide a reproducible dev environment that activates automatically when you `cd` into the project directory. No more "works on my machine" — every dev gets the same Elixir, Erlang, Rust, and PostgreSQL.
## How It Works
1. **Nix flakes** (`flake.nix`) declare every tool and library the project needs, pinned via `flake.lock`.
2. **direnv** watches for `.envrc` and loads/unloads the environment as you enter/leave the directory.
3. **nix-direnv** bridges the two, so `use flake` in `.envrc` triggers `nix develop`.
The result: `cd` into the repo and you're ready to run `mix phx.server`. `cd` out and your shell is clean. No global installs, no version conflicts, no manual setup.
## File Layout
| File | Purpose |
|---|---|
| `flake.nix` | Top-level flake: pins nixpkgs, Erlang/Elixir versions, defines dev shell |
| `flake.lock` | Locked hashes for every dependency — reproducible across machines |
| `nix/shell.nix` | Dev shell definition: packages, env vars, PostgreSQL service scripts, pre-commit hooks |
| `shell.nix` | Flake-compat shim so `nix-shell` works without `--extra-experimental-features flakes` |
| `.envrc` | Contains `use flake` (gitignored — create it yourself on each machine) |
## One-Time Setup
### 1. Install Nix (multi-user)
```bash
sh <(curl -L https://nixos.org/nix/install)
```
Follow the prompts. The multi-user install (`--daemon`) is recommended on macOS. When it finishes, open a new terminal so the Nix daemon is running.
Enable flakes. Add this to `~/.config/nix/nix.conf` (create the file if needed):
```
experimental-features = nix-command flakes
```
Verify:
```bash
nix --version
nix flake --help
```
### 2. Install direnv
```bash
brew install direnv
```
Add the direnv hook to your shell's rc file. Running `brew info direnv` will print the exact line — pick the one for your shell:
**fish** (`~/.config/fish/config.fish`):
```fish
direnv hook fish | source
```
**zsh** (`~/.zshrc`):
```zsh
eval "$(direnv hook zsh)"
```
### 3. Install nix-direnv
```bash
brew install nix-direnv
```
Then configure direnv to load it. Create (or append to) `~/.config/direnv/direnvrc`:
```bash
mkdir -p ~/.config/direnv
cat > ~/.config/direnv/direnvrc << 'EOF'
source "$(brew --prefix nix-direnv)/share/nix-direnv/direnvrc"
EOF
```
If you installed nix-direnv via `nix profile` instead of Homebrew, the source line is:
```
source $HOME/.nix-profile/share/nix-direnv/direnvrc
```
### 4. Create `.envrc`
```bash
echo 'use flake' > .envrc
```
This is gitignored — each dev creates their own. You can add personal overrides (e.g., extra env vars) here.
### 5. Restart your shell
Open a new terminal tab or `exec $SHELL` so the direnv hook takes effect.
### 6. First time entering the project directory
```bash
cd ~/dev/prop
direnv allow # one-time approval — thereafter it loads automatically
```
The first build may take several minutes while Nix downloads and compiles packages (Erlang, Elixir, Rust, PostgreSQL, etc.). Subsequent entries are instant — direnv caches the shell.
## Daily Use
Once set up, just `cd` into the repo. The shell activates automatically:
- **PostgreSQL** starts on port 5432 with `prop_dev` and `prop_test` databases created
- **`start-services`** / **`stop-services`** commands are available to manually control PostgreSQL
- **Mix/Hex** are sandboxed to `.nix-mix` / `.nix-hex` (doesn't pollute `~/.mix`)
- **Pre-commit hooks** (mix format, credo, nixfmt, cargo fmt, cargo clippy) install automatically
- **Services stop** automatically when you `cd` out of the directory
## Troubleshooting
**`direnv: error .envrc is blocked`**
Run `direnv allow` in the project directory.
**`nix develop` fails with "experimental Nix feature 'nix-command' is disabled"**
Add `experimental-features = nix-command flakes` to `~/.config/nix/nix.conf`.
**Empty shell, no Elixir available**
Run `nix develop --command elixir --version` to see build output. Check for syntax errors in `flake.nix` or `nix/shell.nix`:
```bash
nix flake check
```
**Flake can't see new or modified files**
Nix flakes only see files tracked by git. If you added a new nix file, stage it first:
```bash
git add -N <file>
```
**Nix build fails with a hash mismatch in `shell.nix` (flake-compat)**
The `flake-compat` tarball hash in `shell.nix` is stale. Update it to the actual hash shown in the error message.
**direnv not triggering on `cd`**
Check your shell's rc file has the hook line from step 2. Run `direnv status` in the project directory.
**I added a new package to `nix/shell.nix` — how do I reload?**
direnv watches `flake.nix` and `flake.lock` for changes and rebuilds automatically. If it doesn't pick up the change, run `direnv reload`.
## Updating Dependencies
The flake pins everything via `flake.lock`. To update all inputs to their latest versions:
```bash
nix flake update
```
To update just nixpkgs (e.g., to get a newer Erlang/Elixir):
```bash
nix flake lock --update-input nixpkgs
```
Commit the updated `flake.lock` so everyone gets the same versions.

280
nix/shell.nix Normal file
View file

@ -0,0 +1,280 @@
{
lib,
stdenv,
mkShell,
writeShellScriptBin,
# Elixir/Erlang
elixir,
# Database
postgresql_16,
# Rust toolchain
rustup,
rust-analyzer,
# Development tools
git-lfs,
inotify-tools,
# LSPs and formatters
elixir-ls,
nixfmt,
shellcheck,
# Pre-commit hooks
pre-commit-hooks,
}:
let
# PostgreSQL data directory (local to project)
pgDataDir = ".nix-postgres";
pgPort = "5432";
pgHost = "localhost";
# Service management flag
servicesFlag = ".nix-services-started";
# Script to start PostgreSQL
startServices = writeShellScriptBin "start-services" ''
set -e
echo "Starting development services..."
# Check if already started
if [ -f "${servicesFlag}" ]; then
echo "Services already running (remove ${servicesFlag} to force restart)"
exit 0
fi
# Initialize PostgreSQL if needed
if [ ! -d "${pgDataDir}" ]; then
echo "Initializing PostgreSQL database..."
${postgresql_16}/bin/initdb -D ${pgDataDir} -U $USER --encoding=UTF8 --locale=en_US.UTF-8
# Configure PostgreSQL for development (fast but unsafe for production)
cat >> ${pgDataDir}/postgresql.conf << EOF
# Development configuration
max_connections = 100
shared_buffers = 128MB
fsync = off
synchronous_commit = off
full_page_writes = off
EOF
fi
# Check if PostgreSQL is already running on this port
if ${postgresql_16}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
echo "PostgreSQL already running on port ${pgPort}"
else
# Start PostgreSQL
echo "Starting PostgreSQL on port ${pgPort}..."
${postgresql_16}/bin/pg_ctl -D ${pgDataDir} -l ${pgDataDir}/logfile -o "-p ${pgPort}" start
# Wait for PostgreSQL to be ready
for i in {1..30}; do
if ${postgresql_16}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
break
fi
if [ $i -eq 30 ]; then
echo "PostgreSQL failed to start within 30 seconds"
echo "Check ${pgDataDir}/logfile for details"
exit 1
fi
sleep 1
done
echo "PostgreSQL started successfully"
fi
# Create postgres role if it doesn't exist (needed by some tools)
${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
"SELECT 1 FROM pg_roles WHERE rolname='postgres'" | grep -q 1 || \
${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -c \
"CREATE ROLE postgres WITH SUPERUSER LOGIN;"
# Create development and test databases
for db in prop_dev prop_test; do
${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='$db'" | grep -q 1 || \
${postgresql_16}/bin/createdb -h ${pgHost} -p ${pgPort} -U $USER $db
done
# Mark services as started
touch ${servicesFlag}
echo ""
echo "Development environment ready!"
echo ""
echo "PostgreSQL: ${pgHost}:${pgPort} (databases: prop_dev, prop_test)"
echo ""
'';
# Script to stop PostgreSQL
stopServices = writeShellScriptBin "stop-services" ''
set -e
echo "Stopping development services..."
# Stop PostgreSQL
if [ -d "${pgDataDir}" ]; then
${postgresql_16}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true
echo "PostgreSQL stopped"
fi
# Remove services flag
rm -f ${servicesFlag}
echo "Services stopped successfully"
'';
# Pre-commit hooks configuration
pre-commit-check = pre-commit-hooks.run {
src = ../.;
hooks = {
# Elixir formatting
mix-format = {
enable = true;
name = "mix format";
entry = "mix format --check-formatted";
files = "\\.(ex|exs)$";
pass_filenames = false;
};
# Credo linting
credo = {
enable = true;
name = "credo";
entry = "mix credo --strict";
files = "\\.(ex|exs)$";
pass_filenames = false;
};
# Rust formatting and linting
cargo-fmt = {
enable = true;
name = "cargo fmt";
entry = "cargo fmt --all --check";
files = "\\.rs$";
pass_filenames = false;
};
cargo-clippy = {
enable = true;
name = "cargo clippy";
entry = "cargo clippy --all-targets -- -D warnings";
files = "\\.rs$";
pass_filenames = false;
};
# Nix formatting
nixfmt = {
enable = true;
entry = "${nixfmt}/bin/nixfmt";
};
# Shellcheck for shell scripts
shellcheck.enable = true;
};
};
in
mkShell {
name = "prop-dev";
# Development tools
buildInputs = [
# Elixir/Erlang
elixir
# Database
postgresql_16
# Rust toolchain
rustup
rust-analyzer
# Development tools
git-lfs
# LSPs and formatters
elixir-ls
nixfmt
shellcheck
# Service management scripts
startServices
stopServices
]
++ lib.optionals stdenv.isLinux [
# Linux-only tools
inotify-tools # For Mix file watching
];
# Environment variables
shellHook = ''
# Set Mix and Hex home to local directories (avoid polluting $HOME)
export MIX_HOME="$PWD/.nix-mix"
export HEX_HOME="$PWD/.nix-hex"
mkdir -p "$MIX_HOME" "$HEX_HOME"
# PostgreSQL connection
export PGHOST="${pgHost}"
export PGPORT="${pgPort}"
export PGUSER="$USER"
export DATABASE_URL="ecto://$USER@${pgHost}:${pgPort}/prop_dev"
# Development secrets (DO NOT use in production)
export SECRET_KEY_BASE="dev_secret_key_base_at_least_64_bytes_long_for_phoenix_to_accept_it_safely"
export RELEASE_COOKIE="dev_release_cookie_for_distributed_erlang"
# Development environment
export MIX_ENV=dev
export PHX_HOST=localhost
export PORT=4000
# Ensure Erlang can find NIF libraries
export ERL_AFLAGS="-kernel shell_history enabled"
# Rust: set up stable toolchain if not already installed
if ! rustup show active-toolchain 2>/dev/null | grep -q stable; then
rustup default stable 2>/dev/null || true
fi
# Install Hex and Rebar3 if not already installed
mix local.hex --force --if-missing > /dev/null 2>&1
mix local.rebar --force --if-missing > /dev/null 2>&1
# Auto-start services on first shell entry
if [ ! -f "${servicesFlag}" ]; then
${startServices}/bin/start-services
# Run migrations if databases exist and Mix deps are available
if [ -d "deps" ] && mix ecto.migrate 2>/dev/null; then
echo "Database migrations applied"
fi
fi
# Install pre-commit hooks
${pre-commit-check.shellHook}
# Welcome message
echo ""
echo ""
echo " "
echo " Prop Microwave Propagation Prediction "
echo " "
echo ""
echo ""
echo "Available commands:"
echo " mix phx.server - Start Phoenix development server"
echo " mix test - Run test suite"
echo " mix precommit - Run pre-commit checks (format, credo, test)"
echo " start-services - Start PostgreSQL"
echo " stop-services - Stop PostgreSQL"
echo " mix ecto.reset - Reset database (drop, create, migrate, seed)"
echo " cargo build - Build Rust crates (cd rust/prop_grid_rs)"
echo " cargo test - Run Rust tests"
echo ""
echo "Services:"
echo " PostgreSQL: ${pgHost}:${pgPort}"
echo ""
echo "Documentation:"
echo " See CLAUDE.md and nix.md for more information"
echo ""
'';
}

7
shell.nix Normal file
View file

@ -0,0 +1,7 @@
# Compatibility shim for developers using `nix-shell` instead of `nix develop`
# This file uses flake-compat to provide the development shell from flake.nix
(import (fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/35bb57c0c8d8b62bbfd284272c928ceb64ddbde9.tar.gz";
sha256 = "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=";
}) { src = ./.; }).shellNix.default