Best practices for a full stack web app (provisioned by Nix)

Cross-posted to Purescript 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: My preferred Haskell build tool (IOG’s Haskell.nix) is very broken right now with the recent major changes to postgresql-libpq. I get lots of errors when I run ‘nix-develop’ in the build which refer to pkg-config not knowing where to find anything with the recent changes. I tried setting env variables in the shell hook but that really never worked. So, I just commented it out and use ‘cabal build’ at the moment until I can possibly fix that.

ps. I also have had similar issues with building Purescript with purs-nix and have abandoned it since the new version of spago was launched.

1 Like

The developer experience that IHP provides, building on devenv, is pretty nice. They also integrate PostgreSQL, so there may be something you can cargo-cult there.

6 Likes

That’s a great idea. Thanks for reminding me about that project!

If you’re not only using nix but NixOS, I often find that, at least on single machine setups, their services shims (which set up systemd services) are enough. They run in a very stable manner, if you manage to avoid the footguns.

I have also written a small blogpost about building a haskell + nix project and deploying it on NixOS. It also uses Postgres. Maybe it’s helpful. (Admittedly the nix part is a bit short but if you have questions, feel free to ask) (+ the project is open source, I won’t link it for crawling reasons but you should be able to find it)

4 Likes

Here’s the issue and fix for it in haskell.nix repo, just setting the flag in cabal.project should be enough `postgresql-libpq` 0.10.2.0 or higher won't build - where's the fix go? · Issue #2281 · input-output-hk/haskell.nix · GitHub

Thanks. I’ll check it out.

I actually implemented that fix and it didn’t fix my issue with Haskell.nix. Maybe I’m missing something and should try it again.

You may be interested in GitHub - juspay/todo-app: A demo Haskell app showing the use of Nix with flake-parts to simplify various dev workflows

This is an example app associated with tutorial series to be finished. But basically, you can use services-flake to define your whole stack (postgres, etc.) in Nix and have the user run it using a single nix run command.

For deployment, this may come in handy.

1 Like

Thanks @srid

I got a pretty decent flake built for using PostgreSQL as a service. Now, I’m working on one that can declare and build all of the backend as a Docker container (since ociTools seems fairly far behind the functionality in dockerTools).

It would be really cool if the nix build could tell what environment it is being built for and do the appropriate thing for each. So, for example if it is being built on a non-NixOS machine, it would automatically provision all of that using Docker and containers instead. And if it being built on NixOS, it could automatically build everything inside of a nix microvm. That would be :pinched_fingers:t3:

Ps. I see you all over the place (GitHub, Nix, Haskell, Rust). Thanks for all that you do for Nix, Haskell, and Rust.

1 Like

I managed to get the service option working really well and reliably.

Here’s what the service module I made looks like:

{ 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 = [
...
    ];
  };
}
1 Like

With IHP, by default you get a locally running postgres when running the dev server; the IHP starter script just automagically starts up postgres in the background when you type ./start and stops it on ctrl+c. I find this really convenient (having previously used wordpress and even mssql locally :grin:) But in prod your app will look for an envvar with the connection string and it’s up to you to provision postgres however you want. This makes a lot of sense, since people may have very different requirements, IHP doesn’t assume you want fully managed or self-hosted or whatever. (You can of course run it this way in dev too.)

2 Likes