Full stack Purescript/Haskell/PostgreSQL web app

Cross-posted to Haskell and Nix’s discourse:

I am working on a FRP web app that runs Purescript on the front end and Haskell (Servant and Opaleye) on the back end talking to a PostgreSQL database.

Right now, PostgreSQL runs natively (I found a flake containing a set of shell scripts from the Elixir discourse that help me start, stop, setup, etc the database) but I can’t help but have the inkling that I’m going about this all wrong.

My instincts say that is far more advisable to do this as a Docker or OCI container (despite my Nixy aversion to those solutions). I don’t think I want to go the route of using postgresql as a NixOS service for the same portability/canonical instincts.

Here’s a link to the relevant branch’s flake:
my current flawed, naive full stack implementation


My question:

My instincts say that I should instead be creating an OCI image that spins up the database rather than running it natively. Is that true?

Can you point me to a canonical example or some documentation that would set me straight on this type of thing? Obviously I tend to do things using 100% Nix but this one has me a little confused since it feels wrong to use Nix for this type of thing.

Basically, I want my entire dev environment to be provisioned and spun up using that one Nix flake. I’m doing this to not only provision my dev environment anywhere but to also deploy this app easily when that time comes. If someone doesn’t mind steering me straight, I’d be incredibly thankful.

Bonus Issue I’ve been struggling with: I have had issues with building Purescript with purs-nix and have abandoned it since the new version of spago was launched.

Yeah I think my instinct would be to spin PG up in a docker container, but I guess it depends on what your deploy story is. I am not super familiar with deploy options that are 100% nix (looks like nixops is in a weird in-between state now, nix-deploy seems quiescent…).

However if you wanted to deploy to something like AWS ECS then it’s handy to be able to produce containers. Have you taken a look at buildImage/buildLayeredImage/etc.? You could try this out with docker-compose and explicitly setting up PG as an entry, with your app as another entry.

Re: building PS in nix: I’m trying to figure that out now, but GitHub - jeslie0/mkSpagoDerivation: Reproducible PureScript projects with Nix looks interesting.

EDIT: sorry I can’t give you advice on getting this all running in a single flake though, although I’d be interested in seeing that myself

1 Like

This is what I have managed so far:

I managed to get the service option working really well and reliably. I will next try to spin that (with Haskell’s API executable) into its own container using Nix MicroVM.

Then after that, I’ll work on a straight Docker implementation. Hopefully I can do that where I have Nix read a docker file so it is nix agnostic (for people that don’t know better.)

Here’s what the service module I made looks like (with lots of little goodies added):

{ config, lib, pkgs, name, ... }: with lib; let cfg = config.services.${pgConfig.database.name}.postgresql; pgConfig = import ./postgresql-config.nix; in { options.services.${pgConfig.database.name}.postgresql = { enable = mkEnableOption "Cheeblr PostgreSQL Service"; package = mkOption { type = types.package; default = pkgs.postgresql; description = "PostgreSQL package to use"; }; port = mkOption { type = types.port; default = pgConfig.database.port; description = "PostgreSQL port number"; }; dataDir = mkOption { type = types.str; default = "/var/lib/postgresql/${config.services.postgresql.package.psqlSchema}"; description = "PostgreSQL data directory"; }; }; config = mkIf cfg.enable { services.postgresql = { enable = true; package = cfg.package; enableTCPIP = true; port = cfg.port; dataDir = cfg.dataDir; ensureDatabases = [ pgConfig.database.name ]; authentication = pkgs.lib.mkOverride 10 '' # Local connections use password local all all trust # Allow localhost TCP connections with password host all all 127.0.0.1/32 trust host all all ::1/128 trust ''; initialScript = pkgs.writeText "${pgConfig.database.name}-init" '' DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${pgConfig.database.user}') THEN CREATE USER ${pgConfig.database.user} WITH PASSWORD '${pgConfig.database.password}' SUPERUSER; END IF; END $$; CREATE DATABASE ${pgConfig.database.name}; GRANT ALL PRIVILEGES ON DATABASE ${pgConfig.database.name} TO ${pgConfig.database.user}; ''; settings = { # Default config max_connections = 100; shared_buffers = "128MB"; dynamic_shared_memory_type = "posix"; log_destination = "stderr"; logging_collector = true; log_directory = "log"; log_filename = "postgresql-%Y-%m-%d_%H%M%S.log"; log_min_messages = "info"; log_min_error_statement = "info"; log_connections = true; }; }; environment.systemPackages = [ cfg.package ]; environment.variables = { PGHOST = "localhost"; PGPORT = toString cfg.port; PGUSER = pgConfig.database.user; PGDATABASE = pgConfig.database.name; DATABASE_URL = "postgresql://${pgConfig.database.user}:${pgConfig.database.password}@localhost:${toString cfg.port}/${pgConfig.database.name}"; }; }; } 

and I broke the config out to one file so I can use the settings in many different iterations of postgresql:

{ ... }: { database = { name = "cheeblr"; user = "postgres"; password = "postgres"; port = 5432; dataDir = "./postgresql"; settings = { max_connections = 100; shared_buffers = "128MB"; dynamic_shared_memory_type = "posix"; log_destination = "stderr"; logging_collector = true; log_directory = "log"; log_filename = "postgresql-%Y-%m-%d_%H%M%S.log"; log_min_messages = "info"; log_min_error_statement = "info"; log_connections = true; listen_addresses = "localhost"; }; }; } 

and here are the utilities I built to work with the database:

{ pkgs , lib ? pkgs.lib , name ? "cheeblr" }: let pgConfig = import ./postgresql-config.nix { }; postgresql = pkgs.postgresql; bin = { pgctl = "${postgresql}/bin/pg_ctl"; psql = "${postgresql}/bin/psql"; initdb = "${postgresql}/bin/initdb"; createdb = "${postgresql}/bin/createdb"; pgIsReady = "${postgresql}/bin/pg_isready"; }; config = { dataDir = pgConfig.database.dataDir; port = pgConfig.database.port; user = pgConfig.database.user; password = pgConfig.database.password; }; mkPgConfig = '' listen_addresses = '${pgConfig.database.settings.listen_addresses}' port = ${toString config.port} unix_socket_directories = '$PGDATA' max_connections = ${toString pgConfig.database.settings.max_connections} shared_buffers = '${pgConfig.database.settings.shared_buffers}' dynamic_shared_memory_type = '${pgConfig.database.settings.dynamic_shared_memory_type}' log_destination = 'stderr' logging_collector = off ''; mkHbaConfig = '' local all all trust host all all 127.0.0.1/32 trust host all all ::1/128 trust ''; envSetup = '' export PGPORT="''${PGPORT:-${toString config.port}}" export PGUSER="''${PGUSER:-${config.user}}" export PGDATABASE="''${PGDATABASE:-${pgConfig.database.name}}" export PGHOST="$PGDATA" ''; validateEnv = '' if [ -z "$PGDATA" ]; then echo "Error: PGDATA environment variable must be set" exit 1 fi ''; in { inherit config; setupScript = pkgs.writeShellScriptBin "pg-setup" '' ${envSetup} ${validateEnv} init_database() { echo "Creating PGDATA directory at: $PGDATA" rm -rf "$PGDATA" mkdir -p "$PGDATA" echo "Initializing database..." ${bin.initdb} -D "$PGDATA" \ --auth=trust \ --no-locale \ --encoding=UTF8 \ --username="${config.user}" # Write config files exactly as in working version cat > "$PGDATA/postgresql.conf" << EOF ${mkPgConfig} EOF cat > "$PGDATA/pg_hba.conf" << EOF ${mkHbaConfig} EOF } start_database() { echo "Starting PostgreSQL..." ${bin.pgctl} -D "$PGDATA" -l "$PGDATA/postgresql.log" start if [ $? -ne 0 ]; then echo "PostgreSQL failed to start. Here's the log:" cat "$PGDATA/postgresql.log" return 1 fi echo "Waiting for PostgreSQL to be ready..." RETRIES=0 while ! ${bin.pgIsReady} -h "$PGHOST" -p "$PGPORT" -q; do RETRIES=$((RETRIES+1)) if [ $RETRIES -eq 10 ]; then echo "PostgreSQL failed to become ready. Here's the log:" cat "$PGDATA/postgresql.log" return 1 fi sleep 1 echo "Still waiting... (attempt $RETRIES/10)" done } setup_database() { echo "Creating database..." ${bin.createdb} -h "$PGHOST" -p "$PGPORT" "$PGDATABASE" if [ $? -ne 0 ]; then echo "Failed to create database" return 1 fi # Use DO block for conditional user creation ${bin.psql} -h "$PGHOST" -p "$PGPORT" "$PGDATABASE" << EOF DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${config.user}') THEN CREATE USER ${config.user} WITH PASSWORD '${config.password}' SUPERUSER; END IF; END \$\$; GRANT ALL PRIVILEGES ON DATABASE ${pgConfig.database.name} TO ${config.user}; EOF } cleanup() { if [ -f "$PGDATA/postmaster.pid" ]; then echo "Stopping PostgreSQL..." ${bin.pgctl} -D "$PGDATA" stop -m fast fi } trap cleanup EXIT init_database && start_database && setup_database echo "Development environment ready:" echo " Socket directory: $PGHOST" echo " Port: $PGPORT" echo " Database URL: postgresql://${config.user}:${config.password}@localhost:$PGPORT/$PGDATABASE" echo "" echo "You can connect to the database using:" echo " ${bin.psql} -h $PGHOST -p $PGPORT $PGDATABASE" ''; pg-start = pkgs.writeShellScriptBin "pg-start" '' ${envSetup} ${validateEnv} echo "Starting PostgreSQL..." ${bin.pgctl} -D "$PGDATA" -l "$PGDATA/postgresql.log" start if [ $? -ne 0 ]; then echo "PostgreSQL failed to start. Here's the log:" cat "$PGDATA/postgresql.log" exit 1 fi echo "Waiting for PostgreSQL to be ready..." RETRIES=0 while ! ${bin.pgIsReady} -h "$PGHOST" -p "$PGPORT" -q; do RETRIES=$((RETRIES+1)) if [ $RETRIES -eq 10 ]; then echo "PostgreSQL failed to become ready. Here's the log:" cat "$PGDATA/postgresql.log" exit 1 fi sleep 1 echo "Still waiting... (attempt $RETRIES/10)" done ''; pg-connect = pkgs.writeShellScriptBin "pg-connect" '' ${envSetup} ${validateEnv} if [ -z "$PGPORT" ]; then echo "Port must be set" exit 1 fi if [ -z "$PGDATABASE" ]; then echo "Database name must be set" exit 1 fi ${bin.psql} -h $PGHOST -p $PGPORT $PGDATABASE ''; pg-stop = pkgs.writeShellScriptBin "pg-stop" '' ${envSetup} ${validateEnv} ${bin.pgctl} -D "$PGDATA" stop -m fast ''; } 

and the current state of my flake:

{ description = "cheeblr"; inputs = { # IOG inputs iogx = { url = "github:input-output-hk/iogx"; inputs.hackage.follows = "hackage"; inputs.CHaP.follows = "CHaP"; inputs.nixpkgs.follows = "nixpkgs"; }; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; iohkNix = { url = "github:input-output-hk/iohk-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; hackage = { url = "github:input-output-hk/hackage.nix"; flake = false; }; CHaP = { url = "github:IntersectMBO/cardano-haskell-packages?rev=35d5d7f7e7cfed87901623262ceea848239fa7f8"; flake = false; }; purescript-overlay = { url = "github:harryprayiv/purescript-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; flake-utils.url = "github:numtide/flake-utils"; flake-compat = { url = "github:edolstra/flake-compat"; flake = false; }; }; outputs = { self, nixpkgs, flake-utils, iohkNix, CHaP, iogx, purescript-overlay, ... }: { nixosModules = { postgresql = import ./nix/postgresql-service.nix; default = { ... }: { imports = [ self.nixosModules.postgresql ]; }; }; } // flake-utils.lib.eachSystem ["x86_64-linux" "x86_64-darwin" "aarch64-darwin"] (system: let name = "cheeblr"; lib = nixpkgs.lib; overlays = [ iohkNix.overlays.crypto purescript-overlay.overlays.default ]; pkgs = import nixpkgs { inherit system overlays; }; # Shell apps postgresModule = import ./nix/postgres-utils.nix { inherit pkgs name; }; vite = pkgs.writeShellApplication { name = "vite"; runtimeInputs = with pkgs; [ nodejs-slim ]; text = '' export CHEEBLR_BASE_PATH="${self}" npx vite --open ''; }; concurrent = pkgs.writeShellApplication { name = "concurrent"; runtimeInputs = with pkgs; [ concurrently ]; text = '' concurrently\ --color "auto"\ --prefix "[{command}]"\ --handle-input\ --restart-tries 10\ "$@" ''; }; spago-watch = pkgs.writeShellApplication { name = "spago-watch"; runtimeInputs = with pkgs; [ entr spago-unstable ]; text = ''find {src,test} | entr -s "spago $*" ''; }; code-workspace = pkgs.writeShellApplication { name = "code-workspace"; runtimeInputs = with pkgs; [ vscodium ]; text = '' codium cheeblr.code-workspace ''; }; dev = pkgs.writeShellApplication { name = "dev"; runtimeInputs = with pkgs; [ nodejs-slim spago-watch vite concurrent ]; text = '' concurrent "spago-watch build" vite ''; }; in { legacyPackages = pkgs; devShell = pkgs.mkShell { inherit name; nativeBuildInputs = with pkgs; [ pkg-config postgresql zlib openssl.dev libiconv openssl ]; buildInputs = with pkgs; [ # Front End tools esbuild nodejs_20 nixpkgs-fmt purs purs-tidy purs-backend-es purescript-language-server spago-unstable # Back End tools cabal-install ghc haskellPackages.fourmolu haskell-language-server hlint zlib pgcli pkg-config openssl.dev libiconv openssl # PostgreSQL tools postgresModule.setupScript postgresModule.pg-start postgresModule.pg-connect postgresModule.pg-stop pgadmin4-desktopmode # pgmanage # pgadmin4 # DevShell tools spago-watch vite dev code-workspace ] ++ (pkgs.lib.optionals (system == "aarch64-darwin") (with pkgs.darwin.apple_sdk.frameworks; [ Cocoa CoreServices ])); shellHook = '' # Set up PostgreSQL environment export PGDATA="$PWD/.postgres" export PGPORT="5432" export PGUSER="postgres" export PGPASSWORD="postgres" export PGDATABASE="${name}" # Run the setup script pg-setup ''; }; }); nixConfig = { extra-experimental-features = ["nix-command flakes" "ca-derivations"]; allow-import-from-derivation = "true"; extra-substituters = [ ... ]; }; } 

My app is really coming along. I’ve now got a minimum viable Deku CRUD app communicating with a Haskell Servant back end.

It’s super easy to spin up using Nix too. Simply clone, enter a devshell with nix develop, then type ‘deploy’ and it sets up the whole thing starting with the PostgreSQL service, then the Haskell back end, then the front end. To stop the app, simply type ‘withdraw’ then ‘pg-stop’ and ‘vite-cleanup’. As they say, “Done and dusted!”

Intentionally, I’ve been waiting to start working on authentication since in some contexts, this app would be run on an internal closed LAN. Eventually the closed LAN version will do some sort of deterministic machine ID verification for communicating with the Haskell back end to prevent unauthorized changes to the db. Have a look at the Security doc to see what the eventual plans look like. I plan to go all out on security on the open web version (obviously) using Websockets and TLS.

Other thoughts:
I was thinking about codegen this week. Reason for thinking about this is that my types are so incredibly domain specific and niche…but this app would be useful in just about any situation where an inventory needs a front end to interact with it. I thought, maybe I could programmatically generate a lot of the domain specific code and boilerplate that I have right now for the domain specific parts since forking and editing this app just feels like so much work.

I’d was thinking that I could perhaps create a Haskell DSL that reads a simple JSON config file and generates all of the types, instances, validation logic, and necessary boilerplate to generate some Purescript files with the correct code. If anyone can advise me on the viability of this, I’d be really appreciative. I’m guessing Haskell (since I’m already using it for the back end, it was kind of a no-brainer to build a small executable to do this) would be a bit more robust than a Purescript nodejs implementation for the task of codegen.

Anyway, here’s where I’m at.

I revised the documentation yesterday. So feel free to peruse and ask me anything. I’m sure there’s a TON that still needs to be done (everything really!). But right now, this just works and building it has taught me a TON. Go easy on me. I may use Haskell, Purescript, and Nix but I’m a bit of a beginner/self-taught tinkerer.

I don’t know why postgres in nix “feels” wrong? You get good versioning of PG, you get simple filesystem (not some obscure container volumes). You know the user who runs PG (this is contrary to Docker which sometimes runs as root, sometimes otherwise). And there is no need for network bridging.

Also, if you want containers, maybe Podman is a better choice than Docker.

1 Like

It doesn’t feel wrong per se. It has been working great, actually. If you look on the “transaction” branch of the repo, I have a whole Nix infrastructure built with all kinds of fancy functionality for everything I’m doing.

It’s just that I imagine that I might be able to run my back end as an OCI image. So, I want to be able to just spin it up in the cloud without having to worry about a whole dedicated Linux machine running it.

I’ve been looking into Talos Linux lately. Seems like it does a lot of what Nix does except for the bare metal container world…and IMO, it’s not a bad thing to have many totally different ways in my back pocket to skin the cat.
I think it could help me provision my app in lot of very different situations. And to me, as a total newb, I tend to look to protect myself from curveballs thrown by unusual client needs like that.

Also can use graphql instead of json api
postgraphql OR hasura + hasura-backend-plus comes to mind (but my boss doesnt like hasura license)

1 Like

I’d have done that had the graphql module not been broken.