Deploying NixOS in a Proxmox LXC container
I prefer to run my Proxmox workloads in LXC containers instead of VMs to reduce overhead and save resources. Thankfully, I was able to follow this guide to get a NixOS LXC template on my local Proxmox instance.
Basic NixOS configuration
Next, we connect to the container and edit /etc/nixos/configuration.nix with a minimal configuration:
{ config, modulesPath, pkgs, lib, ... }:
{
# Use the predefined LXC module
imports = [
(modulesPath + "/virtualisation/proxmox-lxc.nix")
];
nix.settings = { sandbox = false; };
proxmoxLXC = {
manageNetwork = false;
privileged = false;
};
services.fstrim.enable = false; # Let Proxmox host handle fstrim
services.openssh = {
enable = true;
openFirewall = true;
settings = {
PermitRootLogin = "yes";
PasswordAuthentication = true;
};
};
# Cache DNS lookups to improve performance
services.resolved = {
extraConfig = ''
Cache=true
CacheFromLocalhost=true
'';
};
# Install basic system utilities
environment.systemPackages = with pkgs; [
vim
htop
git
];
system.stateVersion = "25.10";
}
We then switch to that config:
nix-channel --update
nixos-rebuild switch --upgrade
Now we have a basic NixOS machine ready to install our monitoring stack on.
Exapanding the configuration
We’ll now prepare to add configurations for the tools we want to install. Let’s first define some imports:
imports = [
(modulesPath + "/virtualisation/proxmox-lxc.nix")
+ ./alertmanager.nix
+ ./prometheus.nix
+ ./grafana.nix
];
As I am using an external load balancer that handles TLS termination and authentication, we open the required ports:
networking.firewall = {
allowedTCPPorts = [
9090 # prometheus
9093 # alertmanager
3000 # grafana
];
};
Formatting your config
If you want to auto-format your config files, you can use nix-shell -p nixfmt --run "nixfmt /etc/nixos/" or nix run nixpkgs#nixfmt on a Flake-based setup (not the case here) to get auto-formatted code.
Prometheus
Prerequisites
The following part assumes you already have some prometheus exporters in your network.
Personally, I used the Ansible collection to deploy the node_exporter and cadvisor to my non-NixOS VMs.
prometheus.nix and define the initial configuration:{ config, pkgs, ... }:
{
services.prometheus = {
enable = true;
webExternalUrl = "https://prometheus.gk.wtf"; # will be used in alerts
globalConfig = {
# override the default from 1m for faster results
scrape_interval = "10s";
evaluation_interval = "10s";
};
# configure the alertmanager
alertmanagers = [
{
scheme = "http";
static_configs = [
{
targets = [
"localhost:${toString config.services.prometheus.alertmanager.port}"
];
}
];
}
];
# define scraping jobs
scrapeConfigs = [
{
# prometheus self-monitoring
job_name = "prometheus";
static_configs = [
{
targets = [
"localhost:${toString config.services.prometheus.port}"
];
}
];
}
{
# alertmanager self-monitoring
job_name = "alertmanager";
static_configs = [
{
targets = [
"localhost:${toString config.services.prometheus.alertmanager.port}"
];
}
];
}
{
# node exporter for Linux VMs
job_name = "node_exporter";
static_configs = [
{
targets = [
"blog-01.int.gk.wtf:9100" # example debian server
];
}
];
}
{
# cadvisor for monitoring docker containers
job_name = "cadvisor";
static_configs = [
{
targets = [
"docker-01.int.gk.wtf:8080" # example docker host
];
}
];
}
];
# this is where our prometheus rules will go
ruleFiles = [
];
};
}
After this, we should be able to access prometheus at https://<vm-ip>:9090.
Adding rules
I started with rules from awesome-prometheus-alerts, as they provide a good baseline.
Instead of rewriting all rules in Nix, I have decided to just import them as YAML, in order to be able to reuse existing rules more easily.
ruleFiles = [
+ ./prometheus-rules/embedded-exporter.yml
+ ./prometheus-rules/node-exporter.yml
+ ./prometheus-rules/google-cadvisor.yml
];
The paths for the rule files are relative to /etc/nixos, where I placed them in the prometheus-rules subdirectory.
High cardinality from Systemd alerts
After the initial setup, I immediately got a PrometheusTimeseriesCardinality alert due the amount of servers and systemd service states.
This can be reduced by only recording failed units:
job_name = "node_exporter";
+ metric_relabel_configs = [
+ {
+ source_labels = [
+ "__name__"
+ "state"
+ ];
+ separator = "_";
+ regex = "node_systemd_unit_state_[^f].*";
+ action = "drop";
+ }
+ ];
Deleting labels
During my experimentation, I moved a lot of jobs for targets around to scrape configurations with different labels. This led to duplicate alerts, which I could only resolve over the admin API:
services.prometheus = {
+ extraFlags = [
+ "--web.enable-admin-api"
+ ];
enable = true;
Then, I could use the following request to clean up old jobs:
curl -X POST \
-g 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match[]={job="alertmanagertest"}'
You should disable the Admin API again afterwards.
Alertmanager
Now we can edit the alertmanager.nix file:
{ config, pkgs, ... }:
{
services.prometheus.alertmanager = {
enable = true;
webExternalUrl = "https://alertmanager.gk.wtf";
configuration = {
global = { };
route = {
group_by = ["instance"]; # group by 'instance' label to prevent spam
group_wait = "30s"; # delay alerts by 30s to collect grouped events
group_interval = "1m"; # notify about updates in the group every minute
repeat_interval = "8h"; # repeat alerts all 8h
receiver = "null"; # Default receiver
};
receivers = [
{
name = "null";
}
];
};
};
}
Telegram integration
I chose to use a telegram bot for notifications; you can find an example on how to get chat_id and bot_token here.
- receiver = "null"; # Default receiver
+ receiver = "telegram"; # Default receiver
};
+ receivers = [
+ {
+ name = "telegram";
+ telegram_configs = [
+ {
+ send_resolved = true;
+ bot_token = "313370000:TmV2ZXJHb25uYUdpdmU__WW91VXA";
+ chat_id = 424242;
+ parse_mode = "Markdown";
+ message = "{{ template \"telegram.message\" . }}";
+ }
+ ];
+ }
{
name = "null";
}
];
Prometheus Dead Man Switch
Now we have an alert called PrometheusAlertmanagerE2eDeadManSwitch that keeps popping up. It is intended as an alert to test alerting an will always be firing.
We’ll just move it to the null receiver:
+ routes = [
+ {
+ matchers = [
+ "alertname=\"PrometheusAlertmanagerE2eDeadManSwitch\""
+ ];
+ receiver = "null";
+ }
+ ];
receiver = "telegram"; # Default receiver
Custom template
In order for our notifications to look pretty, we can use a custom alerting template. There is exactly one gist with an example template available; I did not quite like it and let an LLM write a custom one. It’s still not perfect; maybe I’ll write one from scratch in the future and publish it:/etc/nixos/telegram.tmpl
{{ define "telegram.message" }}
{{- $firing := .Alerts.Firing -}}
{{- $resolved := .Alerts.Resolved -}}
{{- if gt (len $firing) 0 }}
🔥 *FIRING* — {{ len $firing }}
{{ range $firing }}
━━━━━━━━━━━━━━━━━━━━
*{{ or .Annotations.summary .Labels.alertname }}*
{{- with .Annotations.description }}
{{ . }}
{{- end }}
*Severity:* {{ if eq .Labels.severity "critical" }}🟥 critical{{ else if eq .Labels.severity "warning" }}🟨 warning{{ else if eq .Labels.severity "info" }}🟦 info{{ else }}⬜ unknown{{ end }}
{{- if .Labels.instance }}
*Instance:* `{{ .Labels.instance }}`
{{- end }}
*Started:* {{ .StartsAt.Format "2006-01-02 15:04:05" }}
{{- if or .Labels.cluster .Labels.namespace .Labels.job }}
*Context:*
{{- if .Labels.cluster }} • *cluster:* `{{ .Labels.cluster }}`{{ end }}
{{- if .Labels.namespace }} • *ns:* `{{ .Labels.namespace }}`{{ end }}
{{- if .Labels.job }} • *job:* `{{ .Labels.job }}`{{ end }}
{{- end }}
{{- if or .GeneratorURL .Annotations.runbook_url }}
*Links:*
{{- if .GeneratorURL }} [🔎 Query]({{ .GeneratorURL }}){{ end }}
{{- with .Annotations.runbook_url }} | [📘 Runbook]({{ . }}){{ end }}
{{- end }}
{{ end }}
{{ end }}
{{- if gt (len $resolved) 0 }}
✅ *RESOLVED* — {{ len $resolved }}
{{ range $resolved }}
━━━━━━━━━━━━━━━━━━━━
*{{ or .Annotations.summary .Labels.alertname }}*
{{- if .Labels.instance }}
*Instance:* `{{ .Labels.instance }}`
{{- end }}
*Ended:* {{ .EndsAt.Format "2006-01-02 15:04:05" }}
{{- if .GeneratorURL }}
*Links:* [🔎 Query]({{ .GeneratorURL }})
{{- end }}
{{ end }}
{{ end }}
{{ end }}
Then, we update the config to include it:
chat_id = 424242;
+ parse_mode = "Markdown";
+ message = "{{ template \"telegram.message\" . }}";
}
];
}
{
name = "null";
}
];
+ templates = [
+ ./telegram.tmpl
+ ];
Grafana
Now that monitoring and alerting is set up, all that’s left is to set up Grafana for nice visualizations.
We can start off with a simple config:
{ config, pkgs, ... }:
{
services.grafana = {
enable = true;
settings = {
server = {
domain = "grafana.gk.wtf";
root_url = "https://%(domain)s/";
http_addr = "0.0.0.0";
http_port = 3000;
};
};
provision = {
# allows us to provision Grafana automatically
enable = true;
};
};
}
Provisioning datasources
In order to get some datasources in Grafana, we can add the following code in the provision block:
datasources.settings.datasources = [
# Provisioning a built-in data source
{
name = "Prometheus";
type = "prometheus";
url = "http://${config.services.prometheus.listenAddress}:${toString config.services.prometheus.port}";
isDefault = true;
editable = false;
}
{
name = "Alertmanager";
type = "alertmanager";
url = "http://localhost:${toString config.services.prometheus.alertmanager.port}";
editable = false;
jsonData = {
implementation = "prometheus";
};
}
];
This will give us access to both Prometheus (as a TSDB) and Alertmanager (to manage alerts via the Grafana UI).
Provisioning dashboards
In order to provision the Node Exporter and Cadvisor dashboards, we once again add code to the provisioning block:
# Creates a *mutable* dashboard provider, pulling from /etc/grafana-dashboards.
# With this, you can manually provision dashboards from JSON with `environment.etc` like below.
dashboards.settings.providers = [
{
name = "my dashboards";
disableDeletion = true;
options = {
path = "/etc/grafana-dashboards";
foldersFromFilesStructure = true;
};
}
];
Then, we can (in a very ugly way) download the dashboards from the Grafana API and store them in the Nix store:
# see `dashboards.settings.providers` above
environment.etc."grafana-dashboards/1860-node-exporter-full.json".source = builtins.fetchurl {
url = "https://grafana.com/api/dashboards/1860/revisions/42/download";
sha256 = "a4d827eb1819044bba2d6d257347175f6811910f2583fadeaf7e123c4df2125e";
};
environment.etc."grafana-dashboards/19792-cadvisor-dashboard.json".source = builtins.fetchurl {
url = "https://grafana.com/api/dashboards/19792/revisions/6/download";
sha256 = "96348d7c68e6d29ced3ba9a8da4358b8605be6815be52daf6d8be85a44f94971";
};
OIDC
All of the above should give you a fully functional Grafana at http://<vm-ip>:3000.
However, this requires authentication with default user credentials.
As I’ve already configured forward auth externally for the other two services, I will also set up OIDC with my IDP (Authentik) here:
{ config, pkgs, ... }:
+ let
+ idpUrl = "https://idp.gk.wtf";
+ in
{
services.grafana = {
enable = true;
settings = {
+ auth = {
+ signout_redirect_url = "${idpUrl}/application/o/grafana/end-session/";
+ oauth_auto_login = true;
+ };
+ "auth.generic_oauth" = {
+ name = "authentik";
+ enabled = true;
+ client_id = "grafana";
+ client_secret = "T3Nvd2llYyB0aGVuIGFuZCBhZ2FpbiwgQXR0YWNrIG9mIHRoZSBkZWFkLCBodW5kcmVkIG1lbiwgRmFjaW5nIHRoZSBsZWFkIG9uY2UgYWdhaW4sIEh1bmRyZWQgbWVuLCBDaGFyZ2UgYWdhaW4sIERpZSBhZ2Fpbiwg";
+ scopes = "openid email profile";
+ auth_url = "${idpUrl}/application/o/authorize/";
+ token_url = "${idpUrl}/application/o/token/";
+ api_url = "${idpUrl}/application/o/userinfo/";
+ role_attribute_path = "contains(groups, 'authentik Admins') && 'Admin' || 'Viewer'";
+ };
Authentik configuration
See the Authentik documentation on how to configure this on the IDP side.
GitOps and Encryption
In order to have all configuration in Git and not store any plaintext credentials in the repository, we’ll use comin for GitOps and agenix for secrets management.
agenix
First we’ll get the host SSH key of the VM and our own SSH pubkey:
[root@vm:/]# cat /etc/ssh/ssh_host_ed25519_key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGWBHY0tU1y4EjJZXUylLAq36lieBtRSzqPcWzFoXhm7 root@observability
[user@pc:~]# cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILR+yp3S4CsMFq85XQqgB5lcxcOCQm2AeGHpoarPwSNt giank@nanopad
Next, we’ll add the agenix module to our config:
imports = [
(modulesPath + "/virtualisation/proxmox-lxc.nix")
+ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix"
./alertmanager.nix
...
environment.systemPackages = with pkgs; [
vim-full
htop
git
+ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {})
];
After a nixos-rebuild switch, we can now use the agenix binary.
We’ll now create a secrets.nix to configure agenix:
let
observability = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGWBHY0tU1y4EjJZXUylLAq36lieBtRSzqPcWzFoXhm7 root@observability";
me = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILR+yp3S4CsMFq85XQqgB5lcxcOCQm2AeGHpoarPwSNt giank@nanopad";
in
{
"secrets/client-secret.age".publicKeys = [ observability me ];
"secrets/telegram-token.age".publicKeys = [ observability me ];
}
Then, we can encrypt the secret values:
EDITOR=vim
agenix -e secrets/client-secret.age -i /etc/ssh/ssh_host_ed25519_key
agenix -e secrets/telegram-token.age -i /etc/ssh/ssh_host_ed25519_key
After encrypting them, we can officially define them in the configuration.nix:
# AGE
age.secrets = {
authentik-oauth-client-secret = {
file = ./secrets/client-secret.age;
owner = "grafana";
group = "grafana";
mode = "0400";
};
alertmanager-telegram-token = {
file = ./secrets/telegram-token.age;
mode = "0400";
};
};
Now all that’s left is to update the application specific configuration. For Telegram, we can simply use the bot_token_file option:
- bot_token = "313370000:TmV2ZXJHb25uYUdpdmU__WW91VXA";
+ bot_token_file = config.age.secrets.alertmanager-telegram-token.path;
For Grafana, we need to set an environment variable to override the client secret:
systemd.services.grafana.environment = {
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET =
"$__file{${config.age.secrets.authentik-oauth-client-secret.path}}";
};
comin
At the end of this adventure, I realized that switching to flakes was once again inevitable.
So, let’s enable flakes then:
nix.settings.experimental-features = [ "nix-command" "flakes" ];
Create a repository for our future NixOS-only homelab:
.
├── flake.nix
└── hosts
└── observability
├── alertmanager.nix
├── configuration.nix
├── grafana.nix
├── prometheus.nix
├── prometheus-rules
│ ├── embedded-exporter.yml
│ ├── google-cadvisor.yml
│ └── node-exporter.yml
├── secrets
│ ├── client-secret.age
│ └── telegram-token.age
├── secrets.nix
└── telegram.tmpl
Vibe-Code a dynamic flake.nix to resolve our host:
{
description = "NixOS configurations";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
agenix.url = "github:ryantm/agenix";
comin.url = "github:nlewo/comin";
};
outputs = { self, nixpkgs, agenix, comin, ... }:
let
system = "x86_64-linux";
lib = nixpkgs.lib;
mkHost = name:
lib.nameValuePair name
(lib.nixosSystem {
inherit system;
modules = [
./hosts/${name}/configuration.nix
agenix.nixosModules.default
comin.nixosModules.comin
];
});
hosts =
builtins.filter
(name: builtins.pathExists ./hosts/${name}/configuration.nix)
(builtins.attrNames (builtins.readDir ./hosts));
in
{
nixosConfigurations =
lib.listToAttrs (map mkHost hosts);
};
}
We essentially copy all config files over and commit them to the repository. As we now also have comin available, we can configure it:
services.comin = {
enable = true;
hostname = "observability";
remotes = [{
name = "origin";
url = "https://github.com/gianklug/nixos-vms";
branches.main.operation = "switch";
}];
};
Clone the git repository on the VM and switch to the flake-based configuration:
nixos-rebuild switch --flake .#observability
You can check the health of comin with journalctl -xefu comin; all changes to the Git repo should now be automatically rolled out.
Conclusion
- Using NixOS for a Homelab is pretty cool and sensible
- Next time, start with flakes from the beginning as you’ll need them eventually
- Agenix and comin are pretty cool tools and I’m looking forward to exploring them more
My current setup is available at github.com/gianklug/nixos-vms.