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 packageversion
- current version of the packagesrc
- the source code, which we fetch from github using a specific commit hash. You can refer to various fetchers herecargoLock
- lockfile generated by cargonativeBuildInputs
- 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
orflake.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.