Functional reactive web and mobile applications, with batteries included. Obelisk's goal is to represent a cohesive, highly-curated set of choices that Obsidian Systems has made for building these types of applications in a way that is extremely fast but does not compromise on production readiness. Supports both WASM and GHCJS frontend targets, GHC 9.14, and deploys to NixOS servers or OCI containers.
Obelisk allows you to build high-quality web applications very quickly using Reflex. In minutes you can go from an empty directory to an interactive application running in the browser, all from a single Haskell codebase! The same frontend assets can be packaged as a mobile app with CapacitorJS (see Native Apps). Obelisk's development environment also enables extremely rapid development and feedback. You can take advantage of Haskell's type system across the frontend and backend boundary. This means changes to your backend that would break your frontend are immediately detected during development and vice versa. Obelisk uses Haskell's compiler to give you a complete "TODO list" of what needs to be updated.
Obelisk is targeted primarily at Haskell developers who want to build high-quality web applications in Haskell, without the distractions of manually choosing and integrating technology for every piece of the system.
Obelisk assumes basic knowledge of Haskell and Reflex/Reflex-DOM, web technologies like HTML and CSS, and a terminal shell like Bash. Knowledge of Nix helps but is not required for day-to-day development: once the cross-toolchain is on your PATH, the edit loop is plain cabal (see docs/cabal.md). Production builds and the shipped static-asset pipeline do use nix.
Prerequisites: Nix with the reflex-frp binary caches configured; see docs/setup.md. Without the caches your first build compiles the toolchain from source.
Scaffold a project without cloning anything by hand:
nix run github:obsidiansystems/obelisk#init -- my-app
# Nix < 2.27: nix run 'github:obsidiansystems/obelisk?submodules=1#init' -- my-app
cd my-app
nix-shell -A haskell-nix
ob-runOpen http://localhost:8000 in your browser. The project carries obelisk as a
git submodule at deps/obelisk, pinned to the revision it was scaffolded
from, with obelisk's own submodules checked out. Bump the pin with:
git -C deps/obelisk fetch
git -C deps/obelisk checkout <rev>
git -C deps/obelisk submodule update --init --recursiveEvery build reads that submodule, so clone the project with
--recurse-submodules, or run git submodule update --init --recursive
afterwards. On Nix < 2.27, also add ?submodules=1 to the project's flake
URL.
Set this once on any machine that works with submodule-pinned dependencies:
git config --global submodule.recurse true git config --global diff.submodule logThe first keeps
deps/at the pinned revision acrossclone,pull, andcheckout. The second shows a pin bump as the commits it moves across. See docs/setup.md.
From an obelisk checkout, run nix-shell -A haskell-nix and then
ob-init my-app instead. ob-init --help explains pinning versus linking.
A typical obelisk project:
my-app/
backend/ # Snap backend server
common/ # Shared types and routes
config/ # Runtime config (common/, frontend/, backend/)
frontend/ # Reflex-DOM frontend (library + executable)
js/ # GHCJS cross-compilation wrapper
wasm/ # WASM cross-compilation wrapper
static/ # Static assets (css, images, etc.)
generate # Static assets generation script
generated/ # Generated static module (obelisk-generated-static)
custom/ # Custom Setup.hs for static manifest generation
deps/
obelisk/ # obelisk itself, as a git submodule
nix-haskell # Symlink to obelisk/deps/nix-haskell
nixpkgs # Symlink to nix-haskell/pins/nixpkgs
reflex-dom # Symlink to obelisk/deps/reflex-dom
cabal.project # Cabal project file
project.nix # Nix-haskell project configuration
inputs.nix # Dependency sources, from deps/ and flake.lock
default.nix # Nix entry point
shell.nix # Nix shell entry point
release.nix # Release matrix (every driver and frontend target)
flake.nix # Flake entry point (optional)
Enter the nix shell to get all build tools (GHC, cabal, cross-compilers, hoogle):
nix-shell -A haskell-nix # or: nix developScaffold a new Obelisk project from the skeleton template into a fresh directory:
ob-init my-app # create ./my-app from the skeleton
ob-init my-app --name my-app # also set the project nameWatch-and-rebuild development server. Rebuilds the backend on .hs, .cabal, or .project file changes, or when you press Enter:
ob-run # serve on :8000
ob-run -- --port=8017 # args after -- go to cabal run (then the backend)Disables optimizations (-O0) for fast rebuilds. Cross-compiles the frontend (WASM by default) in the background during each build via the backend's custom Setup.hs.
Start a REPL with optimizations disabled:
ob-repl # loads backend, common, frontend
ob-repl lib:common # load specific targetContinuous type-error feedback via ghcid: recompiles to bytecode on every save and reports errors at GHCi speed, without starting a server or cross-compiling the frontend:
ob-watch # watches backend, common, frontend
ob-watch lib:common # watch a specific targetLocal Hoogle documentation server:
ob-hoogle start # starts on port 8080
ob-hoogle stop
ob-hoogle restartAutomatically stopped when exiting the nix shell.
Minimal deploy helper that builds the NixOS configuration and activates it on a remote host over SSH:
ob-deploy <nixos-config> <user@host><nixos-config> is the NixOS system configuration that imports the obelisk server module (see NixOS Server); <user@host> is the SSH target to deploy to.
Obelisk uses a nix-haskell module that declares all build configuration as NixOS-style options.
See docs/module.md for obelisk-specific options and docs/nix-haskell for the full nix-haskell module documentation.
{ pkgs, obeliskLib, ... }:
{
name = "my-app";
src = ./.;
# Obelisk libraries provided as source-repository-packages
inherit (obeliskLib) source-repository-packages;
# Static assets path (hashed + compressed automatically)
obelisk.static.path = import ./static { inherit pkgs; };
# Public config (common/ + frontend/) bundled into the production server
obelisk.config.path = ./config;
# Frontend target: "wasm" (default) or "js"
# obelisk.frontend.target = "wasm";
shell = {
crossPlatforms = ps: with ps; [ wasi32 ]; # add ghcjs for `-f js` builds
withHoogle = true;
};
}Dependencies live under inputs, keyed the way flake inputs are. An entry accepts whatever a flake input can be: a flake input, a store path, a checkout, or a packed nix-thunk.
Without flakes, inputs.nix builds the set from two sources, the second winning:
- The inputs of
flake.lock, when the file exists. - Every directory or symlink under
deps/. A packed nix-thunk resolves to the source it pins. A symlink to a git checkout is fetched with its submodules.
The skeleton's deps/nixpkgs, deps/nix-haskell and deps/reflex-dom are symlinks into the obelisk submodule. nix-haskell's other pins (haskell-nix, ghc-wasm-meta, sandstone) come with it.
The flake commands pass the flake's own inputs instead. The skeleton's flake.nix follows obelisk's:
{
inputs = {
self.submodules = true;
obelisk.url = ./deps/obelisk;
nix-haskell.follows = "obelisk/nix-haskell";
nixpkgs.follows = "obelisk/nixpkgs";
flake-compat.follows = "obelisk/flake-compat";
};
}To override one, give it a URL in place of the follows. An entry of your own is carried through the same way:
{
inputs = {
self.submodules = true;
obelisk.url = ./deps/obelisk;
nix-haskell.follows = "obelisk/nix-haskell";
flake-compat.follows = "obelisk/flake-compat";
# A different nixpkgs.
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
# A different haskell.nix.
haskell-nix.url = "github:input-output-hk/haskell.nix";
# A different ghc-wasm-meta, the wasm GHC bindist the nixpkgs driver
# builds the wasm target with.
ghc-wasm-meta = {
url = "github:haskell-wasm/ghc-wasm-meta";
flake = false;
};
# A different reflex-dom.
reflex-dom = {
url = "github:reflex-frp/reflex-dom/develop";
flake = false;
};
# Available in project.nix as config.inputs.some-flake.
some-flake.url = "github:someone/some-flake";
};
}An input the flake declares can also be replaced for one command:
nix build --override-input haskell-nix github:input-output-hk/haskell.nixAn entry set in project.nix wins over both, and works without flakes:
{
inputs.haskell-nix = ./deps/haskell.nix; # checkout, or a packed nix-thunk
}Precedence runs flake inputs < deps/ < project.nix. See docs/nix-haskell for the full option.
inputs.reflex-dom is upstream reflex-dom, from deps/reflex-dom or from a reflex-dom flake input.
Add dependencies to the build-depends field in the appropriate .cabal file. Nix picks up the corresponding packages from the haskell.nix package set automatically.
To add extra local packages, add the package directory to the packages: stanza in cabal.project and create a .cabal file for it.
For packages from git, use native source-repository-package stanzas in cabal.project:
source-repository-package
type: git
location: https://github.com/someone/some-package
tag: abc123Use source-repository-packages in project.nix for local packages, git submodules, nix-thunks, and flake inputs. A source is anything inputs accepts, so a packed nix-thunk can be given as-is and is resolved to the source it pins; subdir selects packages within a source, so a multi-package repository takes one entry rather than one per package:
{ config, ... }:
{
source-repository-packages = {
some-local-package = ./deps/some-local-package; # local path, git submodule, or nix-thunk
some-flake-package = config.inputs.some-flake; # flake input
some-repo = {
src = ./deps/some-repo;
subdir = [ "package-a" "package-b" ];
};
};
}Use hackage-overlays in project.nix to make custom packages visible to the nix cabal solver:
{
hackage-overlays = [
{
name = "some-package";
version = "0.1.0";
src = pkgs.fetchFromGitHub {
owner = "someone";
repo = "some-package";
rev = "abc123";
sha256 = "...";
};
}
];
}Set per-package options (flags, patches, build hooks) directly in
project.nix; they apply to every driver, or under a driver namespace
(nixpkgs.packages..., haskell-nix.packages...) to that driver only:
{
packages.some-package.flags.some-flag = true;
nixpkgs.packages.other-package.patches = [];
}Use haskell-nix.overrides (raw haskell.nix modules) and
nixpkgs.options.overrides (raw overlays over the Haskell package set) for
anything the common options do not cover:
{
haskell-nix.overrides = [
({ config, lib, ... }: {
packages.some-package.components.library.preBuild = "...";
})
];
nixpkgs.options.overrides = [
(self: super: { some-package = pkgs.haskell.lib.dontCheck super.some-package; })
];
}Key module options (see docs/module.md for full reference):
Off by default. nix-haskell can build the native packages one module at a time: sandstone writes one content-addressed derivation per module through Nix dynamic derivations, and the package's own build restores them and only links. A change to one module rebuilds one module. The wasm and js frontends build as before, since cross targets are never built this way.
{
fine-grained.enable = true;
# Every local package by default. Name the ones to build this way:
# fine-grained.packages = [ "backend" "common" ];
# A profiling library or haddock reads every module a second time.
packages.backend.enableLibraryProfiling = false;
packages.backend.doHaddock = false;
}The builds need a Nix with dynamic derivations, which a stock daemon lacks. fine-grained.run wraps one that drives a store of its own under .nix/store at the project root, filled from the machine's store by copy. NIX_DYNAMIC_DRV_STORE moves that store elsewhere.
nix-build -A config.fine-grained.run
./result/bin/fine-grained-nix build -f . haskell-nix.serverExe.wasmAdd .nix/ to the project's .gitignore, or every nix build copies the store into its source. See deps/nix-haskell/README.md for what makes a plan recompile.
Every output is namespaced by the nix-haskell driver that builds it:
haskell-nix (the default: haskell.nix toolchain, wasm and js targets)
or nixpkgs (the Haskell infrastructure of nixpkgs). nixpkgs builds no wasm
GHC of its own, so the skeleton gives that driver one from the ghc-wasm-meta
pin. Both drivers then build both targets.
# Full production build (backend + optimized/compressed frontend)
nix-build -A haskell-nix.serverExe.wasm
nix-build -A haskell-nix.serverExe.js
# or: nix build .#haskell-nix.serverExe.wasm
# The same targets built with the nixpkgs driver
nix-build -A nixpkgs.serverExe.js
nix-build -A nixpkgs.serverExe.wasm
# OCI container image
nix-build -A haskell-nix.containerImage.wasm
# or: nix build .#haskell-nix.containerImage.wasmPlatform note: the nix builds (and the nix dev shell, which provides the cross-compilers) are supported on Linux only:
x86_64-linuxandaarch64-linuxare tested and served by the binary caches. Other systems are exposed but untested; restoring macOS support is tracked as a follow-up.
cabal build backend # native backend
cabal run backend # run with WASM frontend cross-buildThe backend's custom Setup.hs automatically cross-compiles the frontend (WASM or GHCJS) and links static assets during cabal build.
The nix shell is not strictly required, but the cross-build needs more than the cross cabal: wasm32-unknown-wasi-cabal, wasm32-unknown-wasi-ghc, and node on PATH, plus the OBELISK_WASI_SHIM environment variable (the nix shell provides and exports all of these). See docs/cabal.md for the complete plain-cabal workflow: prerequisites (including getting the toolchain from ghc-wasm-meta without nix), static assets, dev runs, production builds, and a manual deploy recipe.
For an end-to-end walkthrough (build, host config, deploy, verify, update), see guides/deploy. The sections below are the reference for each path.
Import the obelisk server module in your NixOS configuration:
{ config, ... }:
let
app = import ./path/to/my-app { system = "x86_64-linux"; };
in {
imports = [ app.serverModule ];
services.obelisk = {
enable = true;
exe = app.haskell-nix.serverExe.wasm;
routeHost = "myapp.example.com";
enableHttps = true;
adminEmail = "admin@example.com";
acmeAcceptTerms = true; # accept the Let's Encrypt / ACME terms of service
};
}This configures nginx (with WebSocket proxy), ACME/Let's Encrypt, systemd service with auto-restart, and firewall rules.
For hosts running multiple obelisk apps, or for user-level (home-manager) deployment, obelisk-systemd provides NixOS and home-manager modules that turn a built serverExe into a systemd service. It works with obelisk v2 unchanged: the serverExe directory (top-level backend binary + assets) is exactly what it expects:
{ ... }:
let app = import ./path/to/my-app { system = "x86_64-linux"; };
in {
obelisks."my-app" = {
obelisk = app.haskell-nix.serverExe.wasm; # a directory, not a bare binary
configSource = "/var/lib/my-app/config";
port = 8000;
enableNginxReverseProxy = true;
virtualHostName = "myapp.example.com";
enableHttps = true;
acmeCertAdminEmail = "admin@example.com";
};
}Here configSource is the authoritative runtime config directory on the host (the full config/, secrets included). It pairs with obelisk.config.path, which bakes only public common/frontend config into the build so backend secrets stay out of the Nix store.
nix-build -A haskell-nix.containerImage.wasm
# or: nix build .#haskell-nix.containerImage.wasm
# Load and run with podman or docker
podman load < result
podman run -p 8000:8000 my-app:latestObelisk v2 builds no native iOS or Android binary. The v1 cross-compile path is gone. CapacitorJS wraps the frontend web assets the browser serves (HTML, JS, WASM) in a native WebView shell for iOS and Android. It exposes OS features (camera, filesystem, push notifications) over a JavaScript bridge that the Haskell frontend reaches through the JavaScript FFI. Nothing mobile-specific is compiled in Haskell.
See docs/mobile.md for the recipe.
For desktop, Tauri or ElectronJS wraps the same assets. Capacitor also supports Electron.
Native rendering without a WebView is a research direction. The candidate is a Reflex renderer for Lynx. miso-lynx shows the approach works for Haskell. miso follows the Elm architecture, so Obelisk needs a Reflex-Lynx renderer first.
skeleton/ is the template ob-init copies. Its layout is the one under Project Structure. Its deps/obelisk is a symlink to the obelisk checkout, which ob-init replaces with a git submodule, or with --link a symlink to a local obelisk.
Refer to FAQ.
Contributions and issue reports are encouraged and appreciated! Refer to the Contributing guide for information about getting started.

