header

i built a webapp for a college project a while back. it uses sqlite, server-side templates, nothing fancy. and to deploy it i was using docker and jenkins but it was kinda heavy on the system as i was also building the docker image on the same system instead of pulling it from docker hub (i just wanted to test something). i wanted something lighter

so i started looking into nix. not nixos, just nix the package manager. the idea was simple: build the binary on the server, no go toolchain required, reproducible every time. and with a self-hosted github actions runner and systemd for service management, i thought i could get push-to-deploy working without too much complexity

the setup

the app lives on a linux server with nix installed in multi-user mode, a github actions self-hosted runner registered to the repo, and a systemd service for the app. as for the deploy flow:

  1. push to master
  2. github actions copies the repo to a release directory on the server
  3. a deploy script runs on the server: builds with nix, copies the output to a current symlink, restarts systemd
  4. done
graph TD A["git push to master"] --> B["GH Actions Runner"] B --> C["copy repo to server"] C --> D["deploy.sh runs"] D --> E["nix build"] E --> F["copy to current/"] F --> G["systemd restart"] G --> H["cloudflare tunnel"]

the nix build

the flake pins nixpkgs to nixos-unstable, overrides go to go_1_24, and builds the module with buildGoModule including sqlite headers. running nix build . on the server produces a result/ symlink with the compiled binary. no go installation needed.

{
  description = "nix flake for [redacted] server";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }:
  let
    system = "x86_64-linux";
    pkgs = import nixpkgs {
      inherit system;
      overlays = [
        (final: prev: {
          go = prev.go_1_24;
        })
      ];
    };
  in {
    packages.${system}.default = pkgs.buildGoModule {
      pname = "server";
      version = "0.1.0";

      src = self;

      vendorHash = "sha256-nm2ByXQ+0b+re3JiNukBYpKK/+VvcTxMiqwukcu/uuM=";
      buildInputs = [ pkgs.sqlite ];

      ldflags = [ "-w" "-s" ];
    };
  };
}

now any machine with nix can build the exact same binary

the github actions workflow

the workflow runs on a self-hosted runner. it triggers on push to master, copies the repo to a release directory on the server, then runs the deploy script

name: Deploy to Production (Nix btw)

on:
  push:
    branches: ["master"]

jobs:
  deploy:
    runs-on: self-hosted

    environment:
      name: production
      url: https://[redacted].bynisarg.in

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Copy repo to releases
        run: |
          mkdir -p /var/www/[redacted]/releases/${GITHUB_SHA}
          rsync -av \
          --exclude='.git' \
          --exclude='.github' \
          --exclude='.gitignore' \
          ./ /var/www/[redacted]/releases/${GITHUB_SHA}
          cp -r . /var/www/[redacted]/releases/${GITHUB_SHA}

      - name: Run deploy.sh on server
        run: |
          bash /var/www/[redacted]/releases/${GITHUB_SHA}/deploy.sh ${GITHUB_SHA}

the runner just pulls the code. the server does the build

github actions output

the deploy script

this script handles the actual deployment and it runs inside the release directory on the server:

#!/usr/bin/env bash
set -e

. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh

SHA="$1"
RELEASES_DIR="/var/www/[redacted]/releases"
CURRENT_DIR="/var/www/[redacted]/current"
RELEASE_DIR="$RELEASES_DIR/$SHA"

echo "Deploying commit $SHA"
cd "$RELEASE_DIR"

echo "Activating release..."
rm -rf "$CURRENT_DIR"
mkdir -p $CURRENT_DIR/views

if [ -d "views" ]; then
    cp -r views $CURRENT_DIR/views
    echo "Copied views/"
fi

echo "Building with Nix..."
nix build .

echo "Copy Nix output into a current release directory"
cp -r result $CURRENT_DIR/result

echo "Restarting systemd service..."
sudo systemctl restart [redacted]

echo "Cleaning old releases (keeping last 5)..."
cd "$RELEASES_DIR"
ls -1 | sort | head -n -"5" | xargs -I {} rm -rf "{}" || true

echo "Deploy complete for commit $SHA!"

the binary ends up at current/result/bin/server, which is what systemd points to

systemd service

the service file is simple:

[Unit]
Description=[redacted] Server
After=network.target

[Service]
User=igris
WorkingDirectory=/var/www/[redacted]/current
ExecStart=/var/www/[redacted]/current/result/bin/server

Environment="[redacted]_DB_PATH=/var/www/[redacted]/data/[redacted].db"
Environment="[redacted]_PORT=:3001"

Restart=always

[Install]
WantedBy=multi-user.target

enable it and it starts on every reboot. systemctl restart [redacted].service pulls in a new deploy

journal output

what i learned

a few things that caught me off guard along the way:

  • nix needs multi-user mode on the server, otherwise the daemon profile path won’t exist
  • the vendorHash in the flake was confusing at first but after figuring out it’s just dependency verifier(?) it made sense
  • github runners need to be re-created for every repo on personal accounts, so using a github org runner was easier (what i did)
  • the commit hash as build output dir idea was from gipitty. the cleanup logic is simple as well: sort releases, keep the last 5.
  • the rollback story is nice too. each deploy lives in its own directory named by commit sha. if something breaks, i just update the current symlink to point to an older release and restart the service

conclusion

i’m not 100% sure if this is a good idea to deploy an app using nix but it was a fun way to do things and move away from traditional devops practices like most people advise. and the server was happy as well as the build wasn’t eating up too much of it’s resources and didn’t keep crashing either (it did crash a few times tho). if you are reading this and know how to actually deploy with nix, i would love to hear from you! comments are always welcome!

comments