We usually deploy our applications on NixOS machines and manage our background processes with systemd. But for our latest project, we decided to leave our beloved Proxmox server behind for something easier to maintain.
We went with AWS, using Terraform to spin up the needed infrastructure. This meant migrating the project to managed AWS services. We wanted to fully leverage the cloud and avoid forcing to maintain a custom NixOS EC2 instance. While components like the database mapped perfectly to AWS managed services (like RDS for PostgreSQL), the Node.js backend obviously needed to be containerized so we could deploy it on ECS.
Here is a quick look at how we handled the migration, the code behind it, and why building containers with Nix actually makes a lot of sense for this kind of setup.
The old setup: Everything on one machine
Before the move, our deployment was pretty standard for a small-to-medium project. We used a NixOS module to define the entire environment on a single server.
If you look at the systemd service definition we were using, it is a classic monolithic setup. The Node backend ran as a native background process, sitting right next to the database, Redis, and our reverse proxy:
systemd.services.kyuss-backend = {
description = "Kyuss Backend Service";
after = ["network.target" "postgresql.service" "redis.kyuss.service" "mosquitto.service"];
requires = ["postgresql.service"];
serviceConfig = {
User = "kyuss-backend";
Group = "kyuss-backend";
WorkingDirectory = "${self.packages.x86_64-linux.backend}";
Environment = [
"PATH=${lib.makeBinPath [pkgs.bash pkgs.nodejs pkgs.coreutils]}"
"MQTT_URL=mqtt://localhost:1883"
"REDIS_HOST=${cfg.redis_host}"
];
ExecStart = "${backendPkg}/start.sh";
Restart = "on-failure";
};
};
This worked fine for a while. NixOS gave us a very predictable server state, and we knew exactly how everything was configured. But running a stateful database on the exact same instance as a stateless API eventually becomes a bottleneck when you need to scale or handle traffic spikes.
Why we moved to Fargate
We needed to decouple things. The plan was to offload the databases to managed AWS services (like RDS and ElastiCache) and run the backend as a stateless container. We went with AWS ECS Fargate because it handles the underlying infrastructure for you. We just wanted to run the container and let AWS worry about the compute.
The standard way to do this is to write a Dockerfile. But Dockerfiles have their quirks. Caching can be a pain, apt-get commands mean builds aren’t truly deterministic, and you usually end up shipping a bunch of unnecessary OS tools in your production image.
Since we were already using Nix, we decided to just use it to build our Docker image instead.
Since we were already using Nix, we decided to just use it to build our Docker image instead.
Building the backend once
One of the nicer things about our setup is how we handle the actual Node application build. We use buildNpmPackage to tell Nix how to compile the NestJS code and run the Prisma generation:
backend = pkgs.buildNpmPackage {
pname = "kyuss-backend";
version = "0.1.0";
src = ./backend;
npmDepsHash = "sha256-VdgF2vNviTUIfMnNwkPmqhk5cktAN5SkWm05flwSXGk=";
buildInputs = [pkgs.openssl prisma.package];
buildPhase = ''
${prismaExportLines}
npx prisma generate
npm run build
'';
# Custom install phase omitted for brevity...
};
The Fargate image
To actually create the Docker image, we usedpkgs.dockerTools.buildLayeredImage. Instead of writing imperative steps like you would in a Dockerfile, you just declare what needs to be in the container:
backend-image = pkgs.dockerTools.buildLayeredImage {
name = "kyuss-backend";
tag = "latest";
contents = [
backend
nodejs
pkgs.bash
pkgs.coreutils
pkgs.openssl
pkgs.cacert
pkgs.tini
(pkgs.runCommand "tmp-dir" {} "mkdir -p $out/tmp")
];
config = {
WorkingDir = "${backend}";
Entrypoint = ["${pkgs.tini}/bin/tini" "--"];
Cmd = ["${pkgs.bash}/bin/bash" "${backend}/start.sh"];
ExposedPorts = {"3000/tcp" = {};};
Env = [
"NODE_ENV=production"
"PATH=/bin"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
] ++ prismaEnvList;
};
};
There are two small but important details in there specifically for Fargate:
pkgs.cacert: Our Nix container doesn’t have a standard Linux OS layer, so it lacks root certificates by default. Adding them explicitly ensures the Node app can make outbound HTTPS requests without failing.pkgs.tini: Fargate shuts down containers by sendingSIGTERMsignal. Node.js doesn’t handlePID1 signal forwarding very well out of the box, which can cause ungraceful shutdowns. Settingtinias the entrypoint handles this cleanly.
Conclusions
By sticking with Nix for the container build, we kept the reliability of our old NixOS server but gained the auto-scaling and lower maintenance of ECS Fargate. The resulting container only contains exactly what the Node app needs to run, no extra OS bloat and every single build is perfectly reproducible. It took a bit of initial configuration to get the flake right, but it has turned into a really solid deployment strategy for us.
