Managing databases users using nix
Posted on Tue 11 February 2025 in nix
Recently I had the following challenge: manage database
(robotic) users using nix. More specifically I wanted
to declare a list of mariadb/mysql users, alongside
passwords, and permissions using nix. And while
nix has some options for this (e.g. services.mysql.ensureUsers.*.name
).
Some aspects can’t be declared with nix options
like the credentials or its permissions for given databases.
There is a way to solve this using nix using three steps: 1. Declaring the user configuration in an object 2. Creating nix packaged scripts that will apply a configuration to the database 3. Creating a systemd unit that runs the previous scripts when appropriate
In this article I will go into details on how this is done. Note: although I focus on MySQL/MariaDB, the underlying principles can be applied for PostgreSQL, Redis, …
Declaring the user configuration¶
The first step is to encapsulate the information required for configuring a user in a nix object (key-value dictionary). In my case it looks like this
dbUsecaseConfig = {
database = "usecase_db";
passwordFile = config.age.secrets.usecasePass.path;
username = "usecase_username";
hostname = "usecase_hostname";
};
Should be self explanatory. Note passwordFile
is a path to
a file, here I use agenix
to manage secrets, which decrypts the
password file with adequate permissions to prevent leaks
(see article about agenix).
Set up script to apply configuration¶
The second step is to create a script that applies the
configuration. This is done using pkgs.writeShellScript
which generates a shell script and stores it as nix
component (under /nix/store/
).
The syntax of the function
writeShellScript "file-name"
''
Content of the file
'';
The file name here is not very important. The content of the script, however, is. To make this modular, I use a function that takes a configuration as input and produces a corresponding shell script:
applyMySQLConf =
{
database,
passwordFile,
username,
hostname
}:
pkgs.writeShellScript "applyMySQLConf"
''
PASSWORD=$(cat ${passwordFile})
${pkgs.mariadb}/bin/mysql -u root -e "GRANT ALL PRIVILEGES ON ${database}.* TO '${username}'@'${hostname}' IDENTIFIED BY '$PASSWORD';"
'';
The details will depends on the use case. For instance for a PostgreSQL database the actual commands used in the script will change.
To generate a script for a given configuration will look this:
dbUsecaseConfig = {
database = "usecase_db";
passwordFile = config.age.secrets.usecasePass.path;
username = "usecase_username";
hostname = "usecase_hostname";
};
usecaseConfScript = applyMySQLConf dbUsecaseConfig;
Automatically apply the configuration using systemd¶
Now, we have a script which applies a configuration, but nothing actually runs the script. This is solved by using systemd services :
systemd.services.apply-mysql-conf = {
description = "Apply MySQL configurations";
wants = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
PremissionsStartOnly = true;
RemainAfterExit = true;
ExecStart = ''
${usecaseConfScript}
...
'';
};
};
I personally use one systemd service to apply all configurations for the database. You may prefer using multiple systemd unit files.
Creating a Nix module¶
It is good practice to create NixOS modules as it allows the code to be reusable and better organised.
A NixOS module is created by adding path/to/module.nix
file.
{
lib,
config,
pkgs,
...
}:
{
# Content of the module
}
More specifically the module will define a NixOS option called
mysqlInitialConfiguration
so it can be used in the configuration.nix
:
{
lib,
config,
pkgs,
...
}:
{
options.mysqlInitialConfiguration = {
enable = lib.mkEnableOption "Activate initial MySQL configuration";
configurations = lib.mkOption = {
default = [ ];
description = ''
List of initial MySQL configurations
For instance :
[
{
database = "usecase_db";
passwordFile = config.age.secrets.usecasePass.path;
username = "usecase_username";
hostname = "usecase_hostname";
}
]
'';
};
};
}
Now this defines the NixOS option but not what happens
when it is enabled and configured. To do so we need to
add the config
part of the option
which applies a
configuration based on option
.
{
lib,
config,
pkgs,
...
}:
{
options.mysqlInitialConfiguration = {
enable = lib.mkEnableOption "Activate initial MySQL configuration";
configurations = lib.mkOption = {
default = [ ];
description = ''
List of initial MySQL configurations
For instance :
[
{
database = "usecase_db";
passwordFile = config.age.secrets.usecasePass.path;
username = "usecase_username";
hostname = "usecase_hostname";
}
]
'';
};
};
config = lib.mkIf config.mysqlInitialConfiguration {
# Actual configuration
};
}
Now we add the actual configuration (systemd files, scripts, …)
and for practical reasons use the let ... in ...
nix clause.
{
lib,
config,
pkgs,
...
}:
let
applyMySQLConf =
{
database,
passwordFile,
username,
hostname,
}:
pkgs.writeShellScript "applyMySQLConf" ''
PASSWORD=$(cat ${passwordFile})
${pkgs.mariadb}/bin/mysql -u root -e "GRANT ALL PRIVILEGES ON ${database}.* TO '${username}'@'${hostname}' IDENTIFIED BY '$PASSWORD';"
'';
# Function taking a list as input and returns a string
genSystemdScript = list: lib.strings.concatStrings (map (conf: applyMySQLConf conf + "\n") list);
systemdScript = pkgs.writeShellScript "mysqlInitialConfiguration" ''
${genSystemdScript cfg.configurations}
'';
cfg = config.mysqlInitialConfiguration;
in
{
options.mysqlInitialConfiguration = {
# ...
};
config = lib.mkIf config.mysqlInitialConfiguration {
systemd.services.mysqlInitialConfiguration = {
description = "Initial MySQL configuration";
wants = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
PermissionsStartOnly = true;
RemainAfterExit = true;
ExecStart = "${systemdScript}";
};
};
};
}
Note that config.mysqlInitialConfiguration
is replaced with
cfg
in the config
part.
Now the new option can be used in the configuration.nix
file
as so:
{
# ...
}:
{
# ...
imports = [
# ...
./path/to/mysql/module.nix
# ...
];
# ...
mysqlInitialConfiguration.enable = true;
mysqlInitialConfiguration.configurations = [
{
database = "usecase_db";
passwordFile = config.age.secrets.usecasePass.path;
username = "usecase_username";
hostname = "usecase_hostname";
}
];
}
Summary¶
In this article I show to apply MySQL user configuration automatically using Nix and put all of this in a module.
For more articles about Nix and NixOS the following RSS feed is available over here.