Deploying FoundryVTT and Forgejo on a NixOS server
part of Personal NixOS Server chain of thoughts
- NixOS is awesome for personal servers!
- -> Deploying FoundryVTT and Forgejo on a NixOS server
I want to share what it is like to manage a NixOS-based personal server. Nix is complex and is not a trivial thing to learn. But it's extremely rewarding after you reach some mastery. Here I highlight my experience with hope to inspire and motivate you to pay attention to Nix and Nix-based technologies.
In this particular article I configure 2 services that already have NixOS modules written for them.
Before configuring them I had a NixOS-based server which I could visit by running ssh root@server and its configuration is a Nix flake placed at /etc/nixos so I can rebuild my system by calling nixos-rebuild --flake /etc/nixos#vps switch.
The goal is to have FoundryVTT and Forgejo services that are:
- running on the server
- available via HTTPS from the Internet
- with auto-generated Let's Encrypt certificates
Nginx
In order to expose services to the Internet we need a reverse proxy. With a single service, you could expose it directly on ports 80/443. But with multiple services, you need a reverse proxy to route requests based on their domains (code.ffloyd.space, vtt.ffloyd.space, etc). Caddy and Nginx are the most popular options.
In non-NixOS environment I'd pick Caddy due to its simple Let's Encrypt (or ZeroSSL) automatic certificates management. It allows you to enable proper HTTPS support with fully valid certificates in no time.
Nginx is more memory efficient, more performant, has more features and is extremely extensible. It's stable 20+ years old software. It can be complex to configure, but I like its flexibility and old-school vibe.
With NixOS I choose Nginx because:
- there're NixOS options that simplify configuration (Caddy also has some)
- auto-HTTPS feature for Nginx is implemented on the NixOS module level (so it negates the main advantage of Caddy for me)
Let me show my nginx.nix, a NixOS module that defines non-service-specific options:
{
# enables Nginx service
services.nginx.enable = true;
# otherwise Nginx will not be accessible from the Internet
networking.firewall.allowedTCPPorts = [80 443];
# /var/lib/acme/.challenges must be writable by the ACME user
# and readable by the Nginx user. The easiest way to achieve
# this is to add the Nginx user to the ACME group.
users.users.nginx.extraGroups = ["acme"];
# This powers the auto-HTTPS feature
# by default it uses Let's Encrypt
security.acme = {
acceptTerms = true;
defaults.email = "your_mail@your.mail.domain";
};
} This short config:
- installs Nginx & ACME tools
- generates configuration for Nginx
- generates systemd services for Nginx
- generates all needed config and systemd units for auto-HTTPS feature
- makes Nginx visible to the external network
Let me show generated nginx.conf as an example:
pid /run/nginx/nginx.pid;
error_log stderr;
daemon off;
events {
}
http {
# Load mime types and configure maximum size of the types hash tables.
include /nix/store/b73wvf83q4cjwzz99pdanbl8qpfawr69-mailcap-2.1.54/etc/nginx/mime.types;
types_hash_max_size 2688;
include /nix/store/6xi5jqgw6mqdvzk36285ywc17wnx1548-nginx-1.30.1/conf/fastcgi.conf;
include /nix/store/6xi5jqgw6mqdvzk36285ywc17wnx1548-nginx-1.30.1/conf/uwsgi_params;
default_type application/octet-stream;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
# $connection_upgrade is used for websocket proxying
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
client_max_body_size 10m;
server_tokens off;
} Just compare complexity of the Nginx config and simplicity of Nix code that generates it. It's a pretty common experience when you work with NixOS — often instead of configuring from scratch you have a module that generates common parts of config from simple definitions. But still allows you to tune it if you are unhappy with defaults.
Also, it allows to combine elements of different configurations in one file. You will see more examples of it below.
FoundryVTT
FoundryVTT is a commercial software. You cannot legally download it without having a licence. But if you're a TTRPG fan I highly recommend considering it. Developers are definitely "no-bullshit" folks and allow you to buy a licence and just use the software. No subscriptions! No additional payments for updating to the next major version! It's rare in the modern software landscape and definitely deserves some support.
I bought a licence a year before I started actively using FoundryVTT. With such type of licence it was nice feeling that I do not need to buy it again just because I had no time to use it.
But for Nix it means that FoundryVTT cannot be part of Nixpkgs. In such cases, the first thing worth doing is to search if someone already wrote a Nix Flake for the subject. You can use what you found or, at least, use it as a reference for manual Nix configuration. In the case of FoundryVTT a pretty decent Nix Flake already exists.
The tricky part of using it is that the FoundryVTT binary you download from the official site should be added to the Nix Store manually. It's a matter of copying one file to the server and executing a command like:
nix-store --add-fixed sha256 FoundryVTT-Linux-13.351.zip Then you need to add foundryvtt module to the server flake inputs:
{
inputs = {
# ...
foundryvtt = {
url = "github:ffloyd/nix-foundryvtt";
inputs.nixpkgs.follows = "nixpkgs";
};
};
# ...
} And then you can add the following NixOS module:
{
inputs,
lib,
config,
pkgs,
...
}: {
imports = [
inputs.foundryvtt.nixosModules.foundryvtt
];
services.foundryvtt = let
inherit (pkgs.stdenv.hostPlatform) system;
inherit (inputs.foundryvtt.packages.${system}) foundryvtt_13;
in {
enable = true;
hostName = "vtt.ffloyd.space";
minifyStaticFiles = true;
upnp = false;
proxySSL = true;
proxyPort = 443;
package = foundryvtt_13;
};
} And after applying you will have FoundryVTT running on the local port 30000. This port is not available from the Internet at the moment and must not be available: FoundryVTT after installation is passwordless. Using SSH we can port-forward it to the local machine:
ssh -N -L 30000:localhost:30000 root@server Then you can prepare it for exposure by setting passwords, etc. And when you're ready you can add the following to the module:
{
inputs,
lib,
config,
pkgs,
...
}: {
# ...
# ...
# ...
services.nginx.virtualHosts.${config.services.foundryvtt.hostName} = let
foundryvttPort = toString config.services.foundryvtt.port;
in {
forceSSL = true;
# enables auto-HTTPS feature, creates all needed
# additional systemd services to handle the process
enableACME = true;
extraConfig = ''
client_max_body_size 300M;
'';
locations."/" = {
proxyPass = "http://localhost:${foundryvttPort}";
extraConfig = ''
# Set proxy headers
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# These are important to support WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
'';
};
};
} And the service is available at https://vtt.ffloyd.space with a valid certificate!
This addition demonstrates multiple advantages of NixOS:
- I can have patches to my niginx config and other configurations in the same file
- I can refer to final values of other parts of configurations (see
config.references) - like in Terraform it solves DRY-related problems. - I (often) can inject literal parts of the config when needed (see
extraConfigoptions)
For example, that's the addition to the Nginx config generated by these options:
{
# ...
# ...
# ...
server {
listen 0.0.0.0:80 ;
listen [::0]:80 ;
server_name vtt.ffloyd.space ;
location / {
return 301 https://$host$request_uri;
}
location ^~ /.well-known/acme-challenge/ {
root /var/lib/acme/acme-challenge;
auth_basic off;
auth_request off;
}
}
server {
listen 0.0.0.0:443 ssl ;
listen [::0]:443 ssl ;
server_name vtt.ffloyd.space ;
http2 on;
ssl_certificate /var/lib/acme/vtt.ffloyd.space/fullchain.pem;
ssl_certificate_key /var/lib/acme/vtt.ffloyd.space/key.pem;
ssl_trusted_certificate /var/lib/acme/vtt.ffloyd.space/chain.pem;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/acme/acme-challenge;
auth_basic off;
auth_request off;
}
location / {
proxy_pass http://localhost:30000;
# Set proxy headers
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# These are important to support WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
client_max_body_size 300M;
}
} See how elegantly HTTPS-related boilerplate is hidden behind one simple enableACME = true option!
That's why simplicity of Caddy in the case of NixOS is not a killer-feature: NixOS + Nginx combo already provides similar simplicity for trivial tasks.
And if something is not covered — you always can solve it once and share as a custom NixOS module with community.
Like all the process of configuring FoundryVTT itself was hidden in the FoundryVTT NixOS module we imported.
You may argue that in this case you need to learn both Nix and target software, while in the other distributions you can focus only on specifics of the software. It's only partially true. Yes, you need to learn Nix, but through it you can postpone learning about target software configuration process. I know I'm ok with FoundryVTT defaults so I just use the module and don't dig into specifics.
NixOS gives you a working and properly configured service faster than distros with manual imperative configuration approach!
Also, after I configured something once - I can easily port it to another NixOS server or even personal laptop! Actually, before having the server I had FoundryVTT installed on my personal laptop in a very similar way.
Yes, Docker and especially docker-compose can bring you somewhat similar experience in the scope of running services, but not in the scope of configuring them.
Agenix
Forgejo needs some secrets:
- a token for SMTP server
- an initial admin password
Considering that I do not push server config anywhere currently I could store them as plaintext values, but it has significant disadvantages even in a such private setup:
- LLMs can read them and send them over the Internet
- it can lead to potential leaks of plaintext secrets in systemd service definitions, etc. — it means overexposure of secrets
I decided to go with my favorite option: Agenix. Explaining Agenix setup is no goal of this article. I will only highlight involved files and final workflow.
With Agenix flake added to inputs, agenix.nix NixOS module is pretty simple:
{
inputs,
lib,
config,
pkgs,
...
}: let
inherit (pkgs.stdenv.hostPlatform) system;
in {
imports = [
inputs.agenix.nixosModules.default
];
environment.systemPackages = [
inputs.agenix.packages.${system}.default
];
} Basically, we only import Agenix's NixOS module and add Agenix CLI to the system.
I have /secrets folder that contains encrypted files.
For example /secrets/forgejo-smtp-token.
I have secrets.nix that defines how those files are encrypted:
let
# Use `ssh-keyscan localhost` to identify local keys
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKUVUw3eHWnygfbaKQ1P4bEoO8tDdd0CSeykNNpBhP0";
server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGFvSBSKcgIDwqUoqfMTggvaWjKHcK58yLQSrEvbq2sC";
in {
"secrets/default-forgejo-admin-password".publicKeys = [laptop server];
"secrets/forgejo-smtp-token".publicKeys = [laptop server];
} In the running system these files are decrypted using a local AGE private key (inferred from your default SSH ED25519 key) and placed somewhere in /var/run folder with proper access rights.
If I need to edit the file I have to use CLI:
agenix -e secrets/forgejo-smtp-token It keeps secrets encrypted during the editing process and config is usable even if you have no key to decrypt (decrypted files will be empty).
Forgejo
Forgejo is open source software and even has a dedicated module in NixOS and documentation on the wiki. So we do not need to search for third-party flake and do not need to add anything in the server flake inputs.
My NixOS module for Forgejo (it's non-trivial, but it was mostly copy-paste of things from official NixOS wiki):
{
inputs,
lib,
config,
pkgs,
...
}: let
forgejo = config.services.forgejo;
in {
# define secrets and its access config
age.secrets = {
default-forgejo-admin-password = {
file = ./secrets/default-forgejo-admin-password;
mode = "400";
owner = "forgejo";
};
forgejo-smtp-token = {
file = ./secrets/forgejo-smtp-token;
mode = "400";
owner = "forgejo";
};
};
# in order to make push-via-ssh by your public key
services.openssh.authorizedKeysInHomedir = true;
services.forgejo = {
enable = true;
database.type = "postgres";
lfs.enable = true;
settings = {
# Uncomment when debugging issues
# DEFAULT = {
# RUN_MODE = "dev";
# };
server = rec {
DOMAIN = "code.ffloyd.space";
# You need to specify this to remove the port from URLs in the web UI.
ROOT_URL = "https://${DOMAIN}/";
HTTP_PORT = 3000;
SSH_PORT = lib.head config.services.openssh.ports;
};
# we rely on autocreation of admin user
service.DISABLE_REGISTRATION = true;
actions.ENABLED = false;
# Proton Mail based SMTP
mailer = rec {
ENABLED = true;
SMTP_ADDR = "smtp.protonmail.ch";
USER = "noreply@ffloyd.space";
FROM = USER;
PROTOCOL = "smtp+starttls";
};
};
secrets = {
mailer.PASSWD = config.age.secrets.forgejo-smtp-token.path;
};
};
services.nginx.virtualHosts.${forgejo.settings.server.DOMAIN} = {
forceSSL = true;
enableACME = true;
extraConfig = ''
client_max_body_size 512M;
'';
locations."/".proxyPass = "http://localhost:${toString forgejo.settings.server.HTTP_PORT}";
};
# Admin user autocreation
systemd.services.forgejo.preStart = let
adminCmd = "${lib.getExe forgejo.package} admin user";
pwd = config.age.secrets.default-forgejo-admin-password;
user = "ffloyd"; # Note, Forgejo doesn't allow creation of an account named "admin"
in ''
${adminCmd} create --admin --email "root@localhost" --username ${user} --password "$(tr -d '\n' < ${pwd.path})" || true
## uncomment this line to change an admin user which was already created
# ${adminCmd} change-password --username ${user} --password "$(tr -d '\n' < ${pwd.path})" || true
'';
} This config is more creative than the previous one. Instead of the 2-phase approach where I port-forward the service to create the first admin user I relied on autocreation of the user. Notice that Nix allows me to patch systemd unit config created by Forgejo module. As a result I can expose the service immediately.
Another awesome part: I do not need to think about Postgres at all - it's autoconfigured by the existing module. Including DB creation. If some other service will autoconfigure Postgres - their configs will be merged (unless conflicts).
I haven't configured CI because I do not need it at the moment, but it's also possible and documented in the NixOS wiki.
Aren't this actually complex?
Yes and no.
Yes if you're not yet proficient with Nix.
No, in the opposite case. Nix is complex, but in the end it significantly reduces overall system management complexity.
By learning NixOS and relying on community-written modules for different software you can achieve more things faster in comparison to configuring each piece of your setup separately. Also, it gives reproducible portability. Not only across servers (hey, Terraform!), but also between servers and personal laptops (as I did in the FoundryVTT case).
Last but not least, learning Nix(OS) to achieve the goals of the article was fun and less frustrating than similar experience with other tools. Might be with exception to Terraform.