With the basic blog setup done and a running server, it was time to publish. I decided to use a wildcard certificate for the webserver, since I plan to add a couple of services and don’t want to add all subdomains to the certificate. Wildcards require to use the DNS-01 challenge with letsencrypt, and in practice, one of the many supported dns providers for automation, which means that we also have to handle secret credentials in our configuration.
Secrets in NixOS
NixOS provides modules to generate the configuration of services, which will be written out to the nix store and be world-readable, meaning readable by anything running on the machine. The credentials for the DNS zone are secrets of very high value and thankfully the authors of the acme service have provided a way to keep those secrets out of the nix store, in a file with stricter permissions.
This file can be set up manually, which would be a perfectly fine solution for a pet server, but it would be nice to have better reproducibilty from source control. The next question then is how to store secrets securely within source control. This is where SOPS comes in. In essence, SOPS wraps your $EDITOR to edit a structured file in clear text and upon saving and exiting, encrypts the values, preserving the structure.
SOPS can be integrated into NixOS configurations with the help of sops-nix. This module let’s us configure ownership and permissions for the automaticallt decryted plaintext secrets under /run/secrets We need to confifure a private key to decrypt the secrets that are encrypted with the corresponding public key. One popular way to set up this private key, is to derive it from a host ssh key, which sops-nix can automate and make sure the generated private key is only readable by root.
Note, that the ssh host key is typically generates on first activation of openssh, which in my case was the first boot of the system. On a new or rebuilt system, fresh ssh host keys will be generated. Unfortunately there is no way around some manual intervention here. We can not provision the secret to decrypt the secrets in a secure manner without introducing yet another non-public secret store.
Configuring sops-nix
In flake.nix, add sops-nix to the inputs:
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};and add the module to the flake modules:
inputs.sops-nix.nixosModules.sopsI added a new module in sops.nix and imported it in configuration.nix:
{ config, ... }:
{
sops = {
defaultSopsFile = ./secrets.yaml;
age = {
sshKeyPaths = [
"/etc/ssh/ssh_host_ed25519_key"
];
# this file will be readable and writable by root
keyFile = "/var/lib/sops-nix/key.txt";
generateKey = true;
};
};
}This derives /var/lib/sops-nix/key.txt from /etc/ssh/ssh_host_ed25519_key and expects a secret named ovhEnv in the encrypted file secrets.yaml in our config repo.
Before we can create secrets.yaml, we have to setup some metadata .sops.yaml. Following Michael Stapelberg’s excellent article on sops-nix, create local age keys for sops. I got myself a nix-shell with some tools for some exploration and derived the keys from my ssh key:
nix-shell -p ssh-to-age age sops
mkdir -p ~/.config/sops/age
ssh-to-age -private-key -i ~/.ssh/id_ed25519 -o ~/.config/sops/age/keys.txt
chmod og-rwx ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txtThe last command resultes in the recipient string for my local system, so that secrets will be encrypted for my local system:
age1k82gyps7pflv7tma04m0q45a9l25ve6s6yg4z7thrdz9ef50cpuszlclh9Next, on the remote system, run
cat /etc/ssh/ssh_host_ed25519_key.pub | nix run nixpkgs#ssh-to-ageage1kxtexj7fv0yejtff7qlms2my7vat2330gqvqnkdd3ejtslvsl4xsy5arckWith that, I created a .sops.yaml. My git repository contains only the configuration for the server. Since I don’t expect to create too many files, I keep everything in a flat root.
keys:
- &admin_mate age1k82gyps7pflv7tma04m0q45a9l25ve6s6yg4z7thrdz9ef50cpuszlclh9
- &server_filts age1kxtexj7fv0yejtff7qlms2my7vat2330gqvqnkdd3ejtslvsl4xsy5arck
creation_rules:
- path_regex: secrets\.yaml$
key_groups:
- age:
- *admin_mate
- *server_filtsNow, in local nix-shell with the tools, run
sops secrets.yamlThis will create some example entries, that can be deleted. The file should look like this:
ovhEnv: |
OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=xxxxxxxx
OVH_APPLICATION_SECRET=xxxxxxxx
OVH_CONSUMER_KEY=xxxxxxxxThe keys can be obtained from the OVH API Token Creation Page. Create a token with the followning access scopes:
GET /domain/zone/*
POST /domain/zone/*
DELETE /domain/zone/*Save and exit the file and secrets.yaml will now look like this:
ovhEnv: ENC[AES256_GCM,data:2TwL1qJZ4QT3y1bJ/NaGlXOObUx57IsM210I/8UTMUhwZk7z/8eX/BMVJ83eyFTp397nHTRllgU+/ARzF2XKm8qKPzAicHh8La35bhHESY7ZyDD9FQz8HacX/9FT4kobx2TKg+u1BS7/0E3Q1JWlqJ+NzLAeXR7WwgEOTVVc09OvcmizpGKHbHSAL/eTZCUayUUYSWTWYTYe1iejyy6AQTeOaA==,iv:lH4H5A+7K6syY02nYGO/69XbA0PHLw+fVHFMZ67r4/I=,tag:kCM3ONtylhrUUMpVfrC/5Q==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5M2tQQm9QYnRJWFdzaWY0
WGF5ajIrcnBJZ0I3ZURnekR1YURWR1puTGdBCjJpVlR5cDVNWThqK1VqM1djbUdJ
NnZadGx1OE5rSkdqb3paNThBcDdOaU0KLS0tIGlES3FldzdYZExoclRQYjNoOUI2
S0czaTQ1UFh4TFBRT05jSll2dS9TUVUKxBIolwm0aYlwnmaFSuIFarR12YiKcMNP
XUk8Pc0MKB0YK83xDYqyplIxCz/Var5g+oOAoCI/uFJWCqlLek02nw==
-----END AGE ENCRYPTED FILE-----
recipient: age1k82gyps7pflv7tma04m0q45a9l25ve6s6yg4z7thrdz9ef50cpuszlclh9
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLSDBseHRjL0NLbnppUzBY
WlJvL2JKbDFUbGpXSmZxR0F3VEEyY2pBckY0CnhMbkJ0b0ZLdFNmVDViNFpIUmlp
WWQ4MHJBRXB4cGdzMDJRVFVOVWpUa1kKLS0tIGtueWZPQ29TTk16NExnMHdKbUZm
c2VJaDdwWWVSM1AxUFdiaVpQUHRYT2MKjf+NeSeIv0y0Id6J1V5Eyo6c7W4ulB+o
RF1HHHx6oHigFOnJjYfxNiutRma4dUYFh+3pdP+ZHhSY6LTXjVZSPQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1kxtexj7fv0yejtff7qlms2my7vat2330gqvqnkdd3ejtslvsl4xsy5arck
lastmodified: "2026-08-02T16:53:54Z"
mac: ENC[AES256_GCM,data:ITjTUvlCWDYWQB5tF/Kou0IHYfEbq1oN1sxJtG49wtOqKR4WjgabR9+OuvCHmDgFmaBuXcwFFCVxbdRwEGJ+Qu2A1rPbobNBy4rOSnQmEfS9t7boiGt99P1eQLYuz9Gt3Dc8v3/vZbIdlBkavdSlKs9bOapDYE4z2ECsnXX+tbI=,iv:oPmzku3zkxrqdOWVAhNdXs1i1Qdg+aXCu+1ALca2eE8=,tag:DNTTyhxvykvsSKfv5Vkhlw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2One useful trick I found to print secrets longer than one line on screen, is to set $SOPS_EDITOR to cat. Depending on the terminal editor, it may be difficult to copy selections to the system clipboard, especially over ssh. The standard output is more compatible in that case.
SOPS_EDITOR=cat sops secrets.yamlConfigure acme and nginx
I created a new module acme.nix and imported it in configuration.nix:
{ config, ... }:
{
sops.secrets.ovhEnv = {
owner = "acme";
group = "acme";
mode = "0400";
};
security.acme = {
acceptTerms = true;
defaults = {
email = "mixis@filts.net";
dnsProvider = "ovh";
};
certs."filts.net" = {
domain = "filts.net";
extraDomainNames = [
"*.filts.net"
];
environmentFile = config.sops.secrets.ovhEnv.path;
dnsPropagationCheck = true;
};
};
}A file named ovhEnv with the token will be created under /run/secrets/ with read permissions for the acme service. The path will be set automatically by the sops-nix module through config.sops.secrets.ovhEnv.path. This requests a wildcard certificate in addition to the apex domain.
Finally the ngingx.nix module is straightforward. One surprise to me was that nginx would respond with my blog on all sub-domains. To prevent that, I added a dummy _default sub-domain that acts as a catch-all for everything unconfigured and terminates the connection:
{
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme.certs."filts.net".reloadServices = [
"nginx"
];
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
recommendedBrotliSettings = true;
recommendedGzipSettings = true;
virtualHosts."filts.net" = {
forceSSL = true;
useACMEHost = "filts.net";
root = "/var/www/filts.net";
locations."/" = {
tryFiles = "$uri $uri/ =404";
};
};
virtualHosts."www.filts.net" = {
forceSSL = true;
useACMEHost = "filts.net";
globalRedirect = "filts.net";
};
# catch-all
virtualHosts."_default" = {
default = true;
rejectSSL = true;
# special nginx code to close connection immediately
locations."/".return = "444";
};
};
users.users.nginx.extraGroups = [ "acme" ];
}The nginx user needs to be added to the acme group to be able to read the certificate key and the nginx service will be reloaded when the certificate gets renewed.
Finishing
Add all the files to git, run nix flake update to update and include sops-nix and commit. To deploy, I run
nixos-rebuild switch --flake .#madalena --target-host root@filts.netFingers crossed. It took me a couple of attempts to iron out some typos and mistakes, but this finally worked. On the server, I followed the acme service by
journalctl -fu acme-order-renew-filts.netFinally I could set up and copy my blog:
mkdir -p /var/www/filts.net
chown m:nginx /var/www/filts.netThen in my local hakyll blog directory:
cabal run . clean
cabal run . build
rsync -ave ssh _site/ filts.net:/var/www/filts.netNo surprises here. Since I precompressed the files with for brotli and gzip, I was curious what nginx will deliver. My Firefox requests both without priorization and nginx delivered brotli. Nice.