feat(portal): add Vultr NixOS host configuration

- use the shared global base and Tailscale module
- configure Portal SSH and VM memory settings
- add Richie's minimal Home Manager profile
- document SOPS and nixos-anywhere deployment
This commit is contained in:
2026-08-26 07:33:31 -04:00
parent b8b0605279
commit 6fa93e1b96
4 changed files with 202 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
# portal_1
Minimal NixOS target for a Vultr VM, installed with nixos-anywhere. The Nix
flake target is `portal_1`; the machine hostname is `portal-1` because DNS
hostnames cannot contain underscores.
## Before deploying
1. Confirm the VM's system disk is `/dev/vda`. If it is not, update both
references in `disk-config.nix`.
2. Confirm the SSH public key in `default.nix` is the key that should have
administrator access.
3. Boot the VM into a NixOS installer or another nixos-anywhere-compatible
Linux rescue environment with root SSH access. Keep this environment
running while completing the SOPS bootstrap below.
## Bootstrap SOPS
Use the rescue environment's SSH host key as the permanent portal identity.
Replace `VM_IP` below:
```console
ssh root@VM_IP 'cat /etc/ssh/ssh_host_ed25519_key.pub' | \
nix shell nixpkgs#ssh-to-age --command ssh-to-age
```
This prints an `age1...` recipient; it does not copy the private key. Add the
recipient to `.sops.yaml`:
```yaml
- &system_portal_1 age1...
```
Then add `*system_portal_1` to the age recipients for
`users/secrets.yaml`. Re-encrypt the existing file for the new recipient and
add the Tailscale key:
```console
nix shell nixpkgs#sops --command sops updatekeys users/secrets.yaml
nix shell nixpkgs#sops --command sops users/secrets.yaml
```
Add the OAuth client secret from the `Auth Keys: Write` credential in the SOPS
editor and save it:
```yaml
tailscale_auth_key: tskey-client-...
```
## Deploy
From the repository root, replace `VM_IP` with the VM's public IP:
```console
nix run github:nix-community/nixos-anywhere -- \
--copy-host-keys --flake .#portal_1 root@VM_IP
```
This repartitions `/dev/vda`, so anything already on that disk is erased. The
layout reserves 8 GiB for swap and assigns the remaining space to the root
filesystem.
`--copy-host-keys` preserves the same private SSH host key at
`/etc/ssh/ssh_host_ed25519_key` on the installed system. SOPS-Nix converts that
key to an age identity during activation. After the reboot, connect as
`richie` and verify that automatic Tailscale enrollment succeeded:
```console
ssh -p 278 richie@VM_IP
sudo tailscale status
```
The installed OpenSSH service listens on port 278. Port 22 is served by
Endlessh and will not provide an SSH login.
HAProxy uses the same frontend, routing, and rate-limiting configuration as
Jeeves. Portal manages the ACME certificates for the existing public domains;
their DNS records must resolve to Portal for HTTP-01 issuance and renewal.
The application backends still use Jeeves' original `127.0.0.1` addresses.
Replace them with the corresponding Tailscale addresses before directing
application traffic through Portal. Ports 80 and 443 are allowed through the
firewall.
+55
View File
@@ -0,0 +1,55 @@
{
inputs,
lib,
pkgs,
...
}:
{
imports = [
inputs.disko.nixosModules.disko
"${inputs.self}/common/global"
"${inputs.self}/users/richie"
"${inputs.self}/common/optional/tailscale.nix"
./disk-config.nix
];
nixpkgs.hostPlatform = "x86_64-linux";
boot = {
# Avoid consuming the VM's limited memory for /tmp.
tmp.useTmpfs = false;
# The Vultr system disk and NIC are exposed as virtio devices.
initrd.availableKernelModules = [
"virtio_pci"
"virtio_blk"
"virtio_scsi"
"sd_mod"
];
};
networking = {
hostName = "portal-1";
useDHCP = lib.mkDefault true;
firewall = {
enable = true;
allowedTCPPorts = [ 278 ];
};
};
services.openssh.ports = [ 278 ];
environment.systemPackages = with pkgs; [
curl
htop
vim
];
# The VM uses its regular swap instead of compressed RAM swap.
zramSwap.enable = false;
time.timeZone = "Etc/UTC";
system.stateVersion = "24.05";
}
+59
View File
@@ -0,0 +1,59 @@
{ ... }:
{
# Vultr's first virtio disk is normally /dev/vda. Change this before
# deployment if the selected image exposes its system disk differently.
disko.devices.disk.main = {
type = "disk";
device = "/dev/vda";
content = {
type = "gpt";
partitions = {
bios = {
size = "1M";
type = "EF02";
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [
"fmask=0077"
"dmask=0077"
];
};
};
swap = {
size = "8G";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
# Install GRUB for both legacy BIOS and EFI-capable Vultr plans.
boot.loader = {
grub = {
enable = true;
devices = [ "/dev/vda" ];
efiSupport = true;
efiInstallAsRemovable = true;
};
efi.canTouchEfiVariables = false;
};
}
+5
View File
@@ -0,0 +1,5 @@
{
imports = [
../home/minimal.nix
];
}