Hands-on NixOS servers
A guide to deploying NixOS servers - without even installing Nix!
Introduction
Deploying a Docker image is easy — just about any platform will do that for you. Deploying a NixOS server, on the other hand, has so far been a lot more work. You need to provision your own server, install NixOS, manage SSH keys and secret encryption, build the configuration, and finally switch to your configuration. This contributes to the sense that Nix/NixOS is complex and impractical.
We've now fixed that. If you have a garnix account, and have the garnix-ci GitHub app installed in a repository that defines a NixOS configuration, all you have to do is push a change, and we'll do the building of your artifacts, the running of your tests, and the provisioning, updating, and deprovisioning of servers for you. Our free tier comes with 2 months of a (4GB RAM, 2 vCPU) server so you can try out Nix/NixOS without commitment. With this, we believe that for a large class of cases, it's easier to get what you want running on a server with NixOS than with Docker, even if you don't yet know Nix/NixOS.
How easy is it to deploy your NixOS server with garnix? Well, let's take running Jitsi as an example. We have a template, which if you:
- fork
- change these two values
- push
Will give you your own Jitsi server.
Jitsi is cool. But by the end of this blog post, we'll show you how to do a lot more. Our hope is that you can see how powerful NixOS is, with immediate, practical feedback — that is, with running servers. You don't need to know anything about Nix or NixOS to follow this post. You don't even need to install Nix! At the end of the post, you'll be speedrunning awesome-selfhosted!
An anatomy lesson
(In this section, we'll look at how configuring your system works on a basic level, without delving too deeply into the details or principles. But even this isn't wholly necessary; you can also just fork one of our template repos and change the TODO lines, and come back when you get stuck.)
For our purposes, the entrypoint into Nix/NixOS is a flake file (named flake.nix, and in the top-level of your repository). That's kind of like a package.json, but much more powerful.
The flake file is a file written in Nix-the-language. The two main components of the flake file are:
- The inputs or dependencies
- The outputs. These can contain things like packages, checks, apps. But for our purposes, the only thing that matters is the hosts (or NixOS configurations).
The outputs take the inputs as argument — a pattern that may be familiar to you as dependency injection. The shape of a flake is thus this:
{ inputs = { ... }; outputs = inputs: { nixosConfigurations = { ... }; }; }
Curly braces are syntax for records/dictionaries (like JSON). The colon is syntax for a function. (Nix-the-language is much like a souped-up JSON.)
You'll notice the nixosConfigurations entry. NixOS configurations are a bit like a Dockerfile, in that they define a deployable unit of code. They differ from a Dockerfile in a number of ways too — most noticeably, in being much more declarative than Dockerfiles. That is, instead of a list of instructions or commands, NixOS configurations look like this:
inputs.lib.nixpkgs.nixosSystem { system = "x86_64-linux"; modules = [ { services.openssh.enable = true; } ]; };
That's a mouthful! In essence, we're calling a function (nixosSystem) and passing it a record of a system (the architecture we're building for) and a (in this case singleton) list of modules. The function is what returns the NixOS configuration.
This configuration, incidentally, is what marks the boundary between Nix and NixOS. "Nix" is a notoriously overloaded term, but roughly refers to both a system for defining packages, and the language in which those packages are defined. NixOS is the specific extension and application of Nix to the defining entire machines (development machines, VMs, containers, production servers, etc.).
You don't have to care about the details. Just remember this pattern, and know that it's inside the modules that you'll be doing most of the work.
We could put all of this directly in the flake.nix file, but it's common to split the module up into a different file:
{ inputs = { ... }; outputs = inputs: { nixosConfigurations = { myMachine = inputs.nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ ./myMachine.nix ]; }; }; } }
And then in myMachine.nix we have:
{ services.openssh.enable = true; }
If you are only defining and deploying a single server, you generally will only have to change the myMachine.nix file. For the rest of the blog post, we'll focus on that, since the flake.nix doesn't need to change.
Why care
With that out of the way, we're ready to show off why NixOS is so powerful. There are quite a number of reasons, but in this blog we'll focus on four:
Tons of services are already packaged
Whether what you want is a Mastodon instance, or your own mailserver, or an analytics platform, it's probably already packaged, either in nixpkgs itself or as a separate repo.
In the former case, often all you have to do is add theservice.enable = true;, as we did with the openssh example above. In the latter case, you need to add the repo to the flake's inputs, and import the module in your NixOS configuration. As an example, garnix has its own module that sets various parameters so your NixOS configuration can be deployed by us. In order to import it, we add it to the inputs of the flake.nix, and to the modules:
{ inputs = { ... garnix-lib.url = "github:garnix-io/garnix-lib"; }; outputs = inputs: { nixosConfigurations = { myMachine = inputs.nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ inputs.garnix-lib.nixosModule.garnix ./myMachine.nix ]; }; }; } }
That makes available new configuration options in myMachine.nix. In particular, we can now set garnix.server.enable = true;, which we need to configure filesystems and networks correctly.
You can search through prepackaged (in nixpkgs) services here. Note that these are services, not packages. There are many more packages, which you can search through in the "Packages" tab of that same site.
We already mentioned the Jitsi example. Here's the entirety of the module configuration (with comments removed):
{ garnix.server.enable = true; services.openssh.enable = true; users.users.me = { isNormalUser = true; description = "me"; extraGroups = [ "wheel" "systemd-journal" ]; openssh.authorizedKeys.keys = [ "ssh-ed25519 ..."; ]; }; services.jitsi-meet = { enable = true; hostName = "jitsi.main.template-jitsi.garnix-io.garnix.me"; }; services.jitsi-videobridge.openFirewall = true; services.nginx.virtualHosts."${host}" = { enableACME = false; forceSSL = false; }; networking.firewall.allowedTCPPorts = [ 80 443 ]; nixpkgs.hostPlatform = "x86_64-linux"; }
That's about 20 lines of code!
Nix is a superpower for deploying services. One of us (Evie) wanted to try out a more customized RSS reader. In an hour or so, she had deployed a tiny-tiny-rss server and an rss-bridge (live here). Another one of us (Alex) has been enjoying running his own searx, which also took hardly any work to set up.
All sorts of stacks are supported
The previous section might make you think NixOS is sort of a great app store for sysadmins. It lets you configure many existing tools. But what about creating your own thing?
This is where Nix the build tool shines. Just about any language is supported, and because of how things are built, dependencies and even top-level packages get cached automatically between runs. Here is an example of a Go backend with a Typescript frontend. As you can see, without any further configuration than the Nix code, CI has been set up, and finishes almost immediately because the build is cached.
Go and Typescript are hardly uncommon stacks. But Nix supports an enormous number of stacks. In fact, there are no fewer than 10 brainfuck compilers or interpreters written in Haskell alone. We even made a little server that uses SSH ForceCommand to provide an SSH-based brainfuck interpreter. You can try it out yourself (the password is "hi"):
ssh me@195.201.218.246 \ '++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++.>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.------.--------.>+.>.'
Composing configurations is easy
Many of the things we mentioned above work with Docker too. But Docker is notoriously annoying to compose. With NixOS, on the other hand, composition is very simple.periodic Borg backup service to your mailserver? Just add:
Want to add aservices.borgbackup.jobs.mybackup = { startAt = "daily"; paths = [ /some/path ]; ... }
After working with NixOS for a while, you realize just how much we sacrificed with Docker and containers. The UNIX philosophy, as we know, was built around compositionality, and when working in the command-line, it's clear how well things work with one another. When it comes to packaging services for deployment, though, we've accepted that in order to get things to interact we need to open ports, create web services, and pile together YAML, or, what's worse, YAML templates. NixOS brings us back into the world of easy composition and abstraction. Part of why this works is because Nix has the facilities of a programming language; and part of it is because Nix can generate all other configurations.
Building and developing software is smoother
Containers can be used as a unit of deployment. But two other ways in which it's become an important part of most developer's lives is by being used to build software, and to run it locally.
For the former, you typically have an image with the compiler and other tools your build process needs, and you then build in that container, and keep the resulting artifact (to, for example, move it into another container for deployment).
For the latter, you usually end up starting services locally by running docker containers containing them.
Nix also supports both of these, but in a much cleaner and more integrated way. You don't have to package everything inside a container, for example. This isn't something we'll go into at greater length today, since the premise of this post is to see how much you can do without installing Nix.
Going your own way
We promised that you'd be able to speedrun through awesome-selfhosted. The formula, then, is this:
- Find what it is that you'd like to deploy.
- Set up a repo with a NixOS configuration with a user for you, with your SSH key, and with openssh enabled.
- Search through NixOS options for it.
- (a) If it's available, set the enable option in your NixOS configuration, as well as any other options you care about.
- (b) If it's not available, search with a search engine for potential flake repositories providing it. If you find it, add the repo to your inputs, and add the module it exports to your modules section. Then enable the options as in (a).
- (c) If neither of those is available, see if the binaries are available as a package. Then create your own systemd service based on it.
- Make sure that you have a garnix.yaml file saying which configurations to deploy from which branch. (Here's an example.)
- Commit and push your changes.
- See it live a couple of minutes later!
And we also recommend:
- Share your work in our Discord channel!
There's of course a lot more to learn about Nix and NixOS. We particularly recommend:
- Gabriella Gonzalez's NixOS in Production. It's not yet finished, but what's there is already invaluable;
- NixOS and Flakes, a book by Ryan Yin that covers many of the same topics; and
- Michael Royal's NixOS Guide as a reference.
Continue Reading
We've added incremental compilation to garnix. In this blog, we discuss prior art on incremental compilation in Nix, and describe our own design.
A short note about custom typing for functions in Nix