back

Deploying applications on a nixos VPS

published: Jul 21, 2025

|

This is a continuation of the blog post i wrote earlier on installing and managing nixos vps. In this post we are gonna package a rust webserver, deploy it on a vps and configure nginx so that its accessible from anywhere.

Writing a Rust Web Server

For demonstration purposes, i have created a simple backend which just reqwests yerkee.com and sends fortune as json response.

// main.rs
use axum::{Router, response::Json, routing::get};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Message {
    message: String,
}

#[derive(Deserialize)]
struct Fortune {
    fortune: String,
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(get_fortune));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running at: http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

async fn get_fortune() -> Result<Json<Message>, StatusCode> {
    let body = reqwest::get("http://yerkee.com/api/fortune")
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .json::<Fortune>()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(Message {
        message: body.fortune,
    }))
}

Now if we run cargo build outside of nix environment, it may fail due to missing openssl dependencies as we are using reqwest crate. During development, we can create a shell.nix or flake.nix that create a reproducible dev environment that can handle locating various libraries and automatically configure environment variables. Similarly while building, we can include packages like pkg-config to locate openssl libraries declaratively.

Before packaging the webserver push the rust source code to any git hosting platform as we are going to fetch the source code from that repo. For convenience i’ll just go with github.

Packaging With Nix Flake

Prefetch the hash of the latest commit by running the following command. Nix references to fetch the code from that specific commit.

$ nix-prefetch-git https://github.com/quantinium3/fortune-cookie.git --branch-name main
{
  "url": "https://github.com/quantinium3/fortune-cookie.git",
  "rev": "1518c312a13dc6593666b2d38c01f24e8719b2e6",
  #...
  "hash": "sha256-ehkM8XvKHysYVaH5xdleVsatCtSfUrWxOC/9ADakGbg=",
  #...
}

Create a flake.nix at the root of our repo. In this flake we define things to successfully build our package

# flake.nix
{
  description = "Fortune cookie backend";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
  };

  outputs = { nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs {
          inherit system;
        };
      in
      {
        packages.default = pkgs.rustPlatform.buildRustPackage rec {
          pname = "fortune-cookie";
          version = "0.1.0";
          src = pkgs.fetchFromGitHub {
            owner = "quantinium3";
            repo = "fortune-cookie";
            rev = "1518c312a13dc6593666b2d38c01f24e8719b2e6"; # replace with the rev we got earlier
            sha256 = "sha256-ehkM8XvKHysYVaH5xdleVsatCtSfUrWxOC/9ADakGbg="; # replace with the hash we got earlier
          };
          cargoLock = { lockFile = ./Cargo.lock; };
          nativeBuildInputs = with pkgs; [ pkg-config ];
          buildInputs = with pkgs; [ openssl ];
        };
      });
}

The flake defines two inputs: nixpkgs - nix package collections and flake-utils - nix utility library that provides helpers like eachDefaultSystem to make sure our package builds for various systems.

Nix also provides a builtin function buildRustPackage which helps in building rust projects. In this function we provide the following attributes:

  • pname - name of the package
  • version - current version of the package
  • src - the source code, which we fetch from github using a specific commit hash. You can refer to various fetchers here
  • cargoLock - lockfile generated by cargo
  • nativeBuildInputs - packages required during the build process but are not linked into the final binary. pkg-config is used to locate libraries like openssl.
  • buildInputs - packages required during the build process and are linked into the final binary

That’s it. Run nix build and it should output a symlink result dir which contains the compiled binary and we can run it using ./result/bin/fortune-cookie.

Next, just commit and push the code to your repo, update the hash and rev in flake.nix and we can test if we can use it as a flake input.

$ nix build github:quantinium3/fortune-cookie && ./result/bin/fortune-cookie
Server running at: http://localhost:3000

Deploying to VPS

Add the fortune-cookie repository as an input in our flake.nix

# flake.nix
inputs.fortune-cookie.url =  "github:quantinium3/fortune-cookie";

and include the package in system configuration.

environment.systemPackages = [ inputs.fortune-cookie.packages.${pkgs.system}.default ];

To ensure that our backend service runs automatically, configure a systemd service

  systemd.services.fortune-cookie {
    description = "Fortune Cookie Backend";
    after = [ "network.target" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      ExecStart = "${inputs.fortune-cookie.packages.${pkgs.system}.default}/bin/fortune-cookie";
      Restart = "always";
      StandardOutput = "journal";
      StandardError = "journal";
    };
  };

Now deploy and we are done. After it is successfully deployed we can test it by doing a curl request.

$ deploy
$ ssh nixie curl http://localhost:3000 | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    61  100    61    0     0    117      0 --:--:-- --:--:-- --:--:--   117
{
  "message": "COBOL:\n\tAn exercise in Artificial Inelegance."
}

To make our backend accessible worldwide via a domain, configure your domain to point to the vps ip address at your dns provider and configure Nginx as reverse proxy so that all the requests to that domain will be routed to our running backend.We can now access our domain worldwide.

  services.nginx = {
    enable = true;

    #... general nginx configuration 
    virtualHosts = {
      "fortune.quantinium.dev" = {
        enableACME = true;
        locations = {
          "/" = {
            proxyPass = "http://127.0.0.1:3000";
          };
        };
      };
    };
  };

  networking.firewall.allowedTCPPorts = [ 80 443 ];

We have successfully deployed our rust server on our nixos vps successfully.

Binary Caching with Cachix

Cachix is a service for hosting and sharing nix binaries. Instead of rebuilding the packages on every deployment or across multiple machines, we can push pre-built binaries to cachix cache and our systems can then download these binaries instead of rebuilding them.

Normally when we rebuild your system, nix checks if there is any change in the input’s source code or dependencies and rebuild the package if there is a change but if no change happens it’ll just reuse the package stored at /nix/store and not rebuild it again. For most part its fine as our system is not much changing but i wanted to include cachix here cause it’s cool.

Since we have a github repo with the code and flake.nix, We can setup a github action that builds the package whenever we change the flake.nix or flake.lock. Why not build on push to main? In our flake.nix we have pinned the commit hash so in order to have a new binary we have update the commit hash and building everytime when main updates is just redundant.

To use the cachix cache we add the following to our configuration

  nix.settings.substituters = [ "https://quantinium3.cachix.org" ]; # your cachix cache
  nix.settings.trusted-public-keys = [ (builtins.readFile ../../secrets/keys/cachix_pub_key)  ]; # your cachix public key

and we can push the binary to cachix on every build using github actions.

name: build and cache fortune
on:
  push:
    branches:
      - main
    paths:
      - 'flake.nix'
      - 'flake.lock'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout repo
        uses: actions/checkout@v4

      - name: install nix action
        uses: cachix/install-nix-action@v31

      - name: install cachix action
        uses: cachix/cachix-action@v15
        with:
          name: quantinium3
          authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

      - name: build package
        run: nix build .#default

      - name: push to cachix
        run: cachix push quantinium3 ./result

      - name: trigger nixos deployment
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.REPO_DISPATCH_TOKEN }}
          repository: quantinium3/nixie
          event-type: fortune_cookie_updated

This workflow:

  • trigger on changes to flake.nix or flake.lock
  • checks out repo, installs nix and cachix
  • builds the package and push the resulting binaries with its dependencies to our cachix cache.
  • dispatches an event to trigger deployment workflow in our server configuration repo.

To automate synching our configuration, we can use the following workflow which receives the event trigger from our upstream package (fortune-cookie)

name: update flake.lock
on:
  repository_dispatch:
    types: [ fortune_cookie_updated ]

concurrency:
  group: update-flake
  cancel-in-progress: true

jobs:
  update:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: checkout repo
        uses: actions/checkout@v4
        with:
          ref: master

      - name: install nix
        uses: cachix/install-nix-action@v31

      - name: update flake.lock
        run: nix flake lock --update-input fortune-cookie

      - name: verify update
        run: git diff --exit-code flake.lock || echo "flake.lock updated"

      - name: commit changes
        uses: stefanzweifel/git-auto-commit-action@v6
        with:
          commit_message: "chore: update fortune-cookie input in flake.lock"

We can also have a second workflow that deploys our server configuration to our vps.

name: deploy nixos configuration
on:
  push:
    branches:
      - master

concurrency:
  group: deploy-vps
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: checkout repo
        uses: actions/checkout@v4
        with:
          ref: master

      - name: install nix
        uses: cachix/install-nix-action@v31

      - name: setup cachix
        uses: cachix/cachix-action@v15
        with:
          name: quantinium3
          authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

      - name: setup ssh
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/nixie
          chmod 600 ~/.ssh/nixie
          echo "${{ secrets.SSH_CONFIG }}" > ~/.ssh/config
          chmod 644 ~/.ssh/config
          echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Deploy to VPS
        run: nix run github:serokell/deploy-rs

Optional: Writing the config in flake.nix

Currently we are downloading the binary to our system and running the systemd service ourselves but this all can be abstracted to the flake and the user only has to import the flake and enable it as we generally do while configuring stuff.

We edit flake.nix to have the config options that install the package and runs is as a systemd service when we enable it in our configuration.

{
  description = "Fortune cookie backend";
  inputs = {
    flake-utils.url = "github:numtide/flake-utils";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
  };
  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs {
          inherit system;
        };
        fortune-cookie = pkgs.rustPlatform.buildRustPackage rec {
          pname = "fortune-cookie";
          version = "0.1.0";
          src = pkgs.fetchFromGitHub {
            owner = "quantinium3";
            repo = "fortune-cookie";
            rev = "f9fb77497c01cfff0a68146f4cf4884e8ddf9145";
            sha256 = "sha256-mKVzDicZVDb7n/zFQ7Fx1t2IUd+pW/pIz/r5UlWcfSQ=";
          };
          cargoHash = "sha256-ASRlHxodqd2/6ZNzADCftrkJS8v6bonlyLI/RtjxGn0=";
          nativeBuildInputs = with pkgs; [ pkg-config ];
          buildInputs = with pkgs; [ openssl ];
        };
      in
      {
        packages.default = fortune-cookie;
        packages.fortune-cookie = fortune-cookie;
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [ rustc cargo pkg-config openssl ];
        };
      }
    ) // {
      nixosModules.fortune-cookie = { config, pkgs, lib, ... }: {
        options.services.fortune-cookie = {
          enable = lib.mkEnableOption "Fortune Cookie Backend service";
        };
        config = lib.mkIf config.services.fortune-cookie.enable {
          systemd.services.fortune-cookie = {
            description = "Fortune Cookie Backend";
            after = [ "network.target" ];
            wantedBy = [ "multi-user.target" ];
            serviceConfig = {
              ExecStart = "${self.packages.${pkgs.system}.fortune-cookie}/bin/fortune-cookie";
              Restart = "always";
              RestartSec = "10s";
              StandardOutput = "journal";
              StandardError = "journal";
              User = "fortune-cookie";
              Group = "fortune-cookie";
              DynamicUser = true;
              NoNewPrivileges = true;
              PrivateTmp = true;
              ProtectSystem = "strict";
              ProtectHome = true;
            };
          };
        };
      };
    };
}

and we can change our configuration to only have one line to enable our backend.

  services.fortune-cookie = {
    enable = true;
  };

If you have reached this far, thank you reading and hope you liked it. If you have any questions or need further clarification, feel free to reach out to me on my socials

Note: if you are trying to officially package stuff for nixpkgs kindly refer to nur-packages-template which provides a well-structured approach to contribute to nixpkgs. Maybe i’ll make a blog on packaging in future.

quantinium 2025