[{"data":1,"prerenderedAt":4719},["ShallowReactive",2],{"post-nixos-on-oracle-cloud":3,"post-nixos-on-oracle-cloud-neighbours":4709,"i-lucide:arrow-left":4715},{"id":4,"title":5,"body":6,"created_at":4694,"description":4695,"extension":4696,"featured_image":4697,"meta":4698,"navigation":628,"path":4699,"published":628,"rawbody":4700,"seo":4701,"stem":4702,"tags":4703,"updated_at":4694,"__hash__":4708},"blog\u002Fblog\u002Fnixos-on-oracle-cloud.md","NixOS on Oracle Cloud's Free ARM Server",{"type":7,"value":8,"toc":4653},"minimark",[9,13,25,32,61,64,74,83,91,96,164,172,177,190,205,212,215,230,234,237,241,244,254,260,267,271,281,285,288,299,305,310,373,383,389,396,400,415,449,452,485,516,520,523,585,593,603,607,732,752,760,764,892,907,911,1246,1257,1268,1274,1283,1302,1305,1314,1325,1331,1338,1342,1362,1383,1387,1427,1442,1449,1453,1468,1477,1485,1496,1516,1522,1525,1544,1547,1552,1580,1587,1595,1602,1617,1621,1628,1648,1655,1697,1700,1704,1719,1780,1791,1825,1829,1838,1844,1851,1858,1861,1885,1889,1909,1915,1992,1995,2009,2028,2158,2173,2177,2194,2239,2246,2267,2273,2385,2399,2408,2427,2431,2434,2438,2443,2461,2472,2478,2513,2524,2528,2534,2568,2575,2675,2682,2698,2719,2723,2738,2741,2745,2748,2792,2810,2814,2820,2835,2858,2862,2876,2906,2921,2925,3697,3729,3732,3794,3813,3817,3824,3939,3950,3975,3984,3988,3991,3998,4036,4046,4265,4273,4281,4287,4296,4300,4305,4310,4330,4335,4385,4410,4423,4426,4430,4576,4579,4585,4589,4592,4623,4632,4641,4649],[10,11,12],"p",{},"I have been deploying my side projects with Dokploy. It's a nice open-source Vercel-like dashboard built on top of docker that you can install on any machine. Write a Dockerfile, push, done. It works but the abstraction makes it hard to learn what's going on underneath.",[10,14,15,16,24],{},"So I got a free ARM instance on ",[17,18,23],"a",{"href":19,"rel":20,"target":22},"https:\u002F\u002Fwww.oracle.com\u002Fde\u002Fcloud\u002F",[21],"noopener","_blank","Oracle Cloud Infrastructure (OCI)"," and set myself a goal:",[10,26,27,31],{},[28,29,30],"strong",{},"Learn NixOS by running real things on it, without Docker."," This post covers everything it took:",[33,34,35,42,45,48,51],"ol",{},[36,37,38,39],"li",{},"Replacing the Ubuntu image with NixOS, using ",[28,40,41],{},"nixos-anywhere",[36,43,44],{},"A small flake config deployed from my laptop with one command",[36,46,47],{},"Caddy with automatic HTTPS (Can be replaced with Nginx or Traefik)",[36,49,50],{},"Publishing the config while keeping my app private",[36,52,53,54,57,58],{},"Deploying a ",[28,55,56],{},"Nuxt 4 and Bun"," site as a sandboxed systemd service, with a ",[28,59,60],{},"deploy on every git push",[10,62,63],{},"I'll include the mistakes too. There were plenty.",[10,65,66],{},[67,68],"img",{"alt":69,"height":70,"src":71,"width":72,"title":73},"A \"Chat, am I cooked?\" meme: a livestream of the NixOS install stuck after \"machine will boot into nixos in 6s\", stamped Instance unresponsive, while the stream chat says he's cooked and skill issue, until a moderator points out that disko never ran and a force reboot fixes it",463,"\u002Fuploads\u002Fblog\u002Fnixos-cooked.webp",560,"Me after kexec left Ubuntu with no disk and no network",[10,75,76,77,82],{},"You can check my config repo ",[17,78,81],{"href":79,"rel":80,"target":22},"https:\u002F\u002Fgithub.com\u002Fdesignbyfaizi\u002Fnixos-config",[21],"here",". Here is the setup we are heading to. Keep this picture in mind.",[10,84,85],{},[67,86],{"alt":87,"height":88,"src":89,"width":90},"Diagram of the finished setup: three lanes lead into the server through two firewalls, Oracle's security list and the NixOS firewall, which only open ports 22, 80 and 443. My laptop reaches sshd over SSH with just deploy, GitHub reaches mysite-deploy over SSH on every push, and visitors reach Caddy over HTTPS. Below, the three GitHub repos: the public nixos-config holds the base system and is imported by the private nixos-infra, which I deploy from, and the private app repo runs the GitHub Action",2124,"\u002Fuploads\u002Fblog\u002Fnixos-overview.webp",1600,[92,93,95],"h2",{"id":94},"the-machine","The machine",[97,98,99,110],"table",{},[100,101,102],"thead",{},[103,104,105,108],"tr",{},[106,107],"th",{},[106,109],{},[111,112,113,129,140,148,156],"tbody",{},[103,114,115,119],{},[116,117,118],"td",{},"Shape",[116,120,121,125,126],{},[122,123,124],"code",{},"VM.Standard.A1.Flex"," (Ampere ARM), ",[28,127,128],{},"2 OCPU \u002F 12 GB",[103,130,131,134],{},[116,132,133],{},"CPU",[116,135,136,137],{},"Neoverse-N1, ",[122,138,139],{},"aarch64-linux",[103,141,142,145],{},[116,143,144],{},"Disk",[116,146,147],{},"46.6 GB boot volume, paravirtualized (virtio-scsi)",[103,149,150,153],{},[116,151,152],{},"Firmware",[116,154,155],{},"UEFI, Secure Boot off",[103,157,158,161],{},[116,159,160],{},"Region",[116,162,163],{},"Frankfurt",[10,165,166],{},[67,167],{"alt":168,"height":169,"src":170,"width":171},"The Oracle Cloud home page on a Free Trial account, showing the instance khakcsar running and the VCN nixos-network",1040,"\u002Fuploads\u002Fblog\u002Fnixos-shot-oci-home.webp",1775,[173,174,176],"h3",{"id":175},"the-free-tier-is-smaller-than-most-guides-say","The free tier is smaller than most guides say",[10,178,179,180,183,184,189],{},"If you look at older tutorials, they say that you get ",[28,181,182],{},"4 OCPU \u002F 24 GB"," of ARM. Oracle's ",[17,185,188],{"href":186,"rel":187,"target":22},"https:\u002F\u002Fdocs.oracle.com\u002Fen-us\u002Fiaas\u002FContent\u002FFreeTier\u002Ffreetier_topic-Always_Free_Resources.htm",[21],"Always Free page"," now says:",[191,192,193],"blockquote",{},[10,194,195,196,200,201,204],{},"All tenancies get the first 1,500 OCPU hours and 9,000 GB hours per month for free ",[197,198,199],"span",{},"..."," For Always Free tenancies, this is equivalent to ",[28,202,203],{},"2 OCPUs and 12 GB of memory",".",[10,206,207,208,211],{},"My account was still on it Free Trial so Oracle happily let me creaate a 4\u002F24 instance. That's double the free allowance and the page above doesn't even say what happens to oversized instances when the trial ends. I resized it to 2\u002F12 (",[28,209,210],{},"Instance → Edit → Shape",", one reboot, no reinstall) before doing anything else.",[10,213,214],{},"Two more rules from the same page are worth sharing:",[216,217,218,224],"ul",{},[36,219,220,221,204],{},"The Always Free instances ",[28,222,223],{},"must be in your home region",[36,225,226,229],{},[28,227,228],{},"Idle instances are reclaimed:"," an instance is taken back if the CPU (95th percentile), network and memory all stay below 20% over 7 days.",[92,231,233],{"id":232},"part-1-preparation","Part 1: Preparation",[10,235,236],{},"Before doing anything destructive, we need to perform some checkups.",[173,238,240],{"id":239},"make-sure-that-the-serial-console-works","Make sure that the serial console works",[10,242,243],{},"If SSH or networking ever breaks, the serial console is the only way in. Test it BEFORE you need it:",[10,245,246,249,250,253],{},[28,247,248],{},"Instance → OS Management → Console connection → Launch Cloud Shell connection",", then press Enter a few times. You should get a ",[122,251,252],{},"login:"," prompt.",[10,255,256],{},[67,257],{"alt":258,"height":169,"src":259,"width":171},"Oracle Cloud Shell opening a console connection to the instance, which reaches the state ACTIVE","\u002Fuploads\u002Fblog\u002Fnixos-shot-serial-console.webp",[10,261,262,263,266],{},"Ubuntu's default user has no password so you can't actually log in there. That's why my NixOS config gives the admin user a password ",[28,264,265],{},"only for the serial console",". SSH stays key-only.",[173,268,270],{"id":269},"back-up-the-boot-volume","Back up the boot volume",[10,272,273,276,277,280],{},[28,274,275],{},"Storage → Boot volume → Backups → Create"," (Full). It doesn't use the VM's disk and the ",[28,278,279],{},"Always Free"," tier includes 5 volume backups. This is actually pretty cool because if things go badly, you can just restore in onto the same instance.",[173,282,284],{"id":283},"open-ports-80-and-443-in-oracles-firewall","Open ports 80 and 443 in Oracle's firewall",[10,286,287],{},"Oracle Cloud Infrastructure (OCI) has a cloud firewall in front of the VM. A port has to be open there AND in the OS firewall.",[10,289,290,291,294,295,298],{},"The VCN wizard in the OCI dashboard creates two subnets: public (",[122,292,293],{},"10.0.0.0\u002F24",") and private (",[122,296,297],{},"10.0.1.0\u002F24","). The instance lives in the public one.",[10,300,301],{},[67,302],{"alt":303,"height":169,"src":304,"width":171},"The VCN's subnets page listing a public subnet 10.0.0.0\u002F24 and a private subnet 10.0.1.0\u002F24","\u002Fuploads\u002Fblog\u002Fnixos-shot-vcn-subnets.webp",[10,306,307],{},[28,308,309],{},"Networking → VCN → public subnet → Security → Default Security List → Add Ingress Rules:",[97,311,312,322],{},[100,313,314],{},[103,315,316,319],{},[106,317,318],{},"Field",[106,320,321],{},"Value",[111,323,324,332,342,350,363],{},[103,325,326,329],{},[116,327,328],{},"Stateless",[116,330,331],{},"off",[103,333,334,337],{},[116,335,336],{},"Source CIDR",[116,338,339],{},[122,340,341],{},"0.0.0.0\u002F0",[103,343,344,347],{},[116,345,346],{},"IP Protocol",[116,348,349],{},"TCP",[103,351,352,358],{},[116,353,354,357],{},[28,355,356],{},"Source"," Port Range",[116,359,360],{},[28,361,362],{},"empty",[103,364,365,368],{},[116,366,367],{},"Destination Port Range",[116,369,370],{},[122,371,372],{},"80,443",[10,374,375,376,379,380,382],{},"Make sure NOT to put 80 and 443 in ",[377,378,356],"em",{}," Port Range. The source port is the visitor's random port so the rule never matches. Oracle splits ",[122,381,372],{}," into two rows when it saves:",[10,384,385],{},[67,386],{"alt":387,"height":169,"src":388,"width":171},"The Default Security List with ingress rules for port 22, two ICMP rules, and the new rules for ports 80 and 443","\u002Fuploads\u002Fblog\u002Fnixos-shot-security-list.webp",[10,390,391,392,395],{},"There's also no rule for ICMP type 8, so for example ",[122,393,394],{},"ping"," command won't work. That's normal.",[173,397,399],{"id":398},"install-nix-on-the-laptop","Install Nix on the laptop",[10,401,402,403,406,407,410,411,414],{},"My laptop runs Arch (btw) and ",[28,404,405],{},"it stays Arch."," It only needs ",[28,408,409],{},"Nix, the package manager",". Nix lives in ",[122,412,413],{},"\u002Fnix"," and never touches the rest of my laptop's system. Here is how I installed it:",[416,417,422],"pre",{"className":418,"code":419,"language":420,"meta":421,"style":421},"language-fish shiki shiki-themes github-light github-dark","sudo pacman -S --needed nix\nsudo systemctl enable --now nix-daemon.service\nmkdir -p ~\u002F.config\u002Fnix\necho \"experimental-features = nix-command flakes\" >> ~\u002F.config\u002Fnix\u002Fnix.conf\n","fish","",[122,423,424,431,437,443],{"__ignoreMap":421},[197,425,428],{"class":426,"line":427},"line",1,[197,429,430],{},"sudo pacman -S --needed nix\n",[197,432,434],{"class":426,"line":433},2,[197,435,436],{},"sudo systemctl enable --now nix-daemon.service\n",[197,438,440],{"class":426,"line":439},3,[197,441,442],{},"mkdir -p ~\u002F.config\u002Fnix\n",[197,444,446],{"class":426,"line":445},4,[197,447,448],{},"echo \"experimental-features = nix-command flakes\" >> ~\u002F.config\u002Fnix\u002Fnix.conf\n",[10,450,451],{},"Two Arch surprises (Nix 2.35):",[33,453,454,475],{},[36,455,456,463,464,467,468,471,472,204],{},[28,457,458,459,462],{},"There is no ",[122,460,461],{},"nix-users"," group"," (",[122,465,466],{},"usermod: group 'nix-users' does not exist","). In older guides and tutorials, they tell you to join it but you don't need to. The daemon socket is world-accessible and ",[122,469,470],{},"allowed-users"," defaults to ",[122,473,474],{},"*",[36,476,477,480,481,484],{},[122,478,479],{},"\u002Fnix\u002Fstore"," didn't exist, so the first command failed with ",[122,482,483],{},"error: opening file \"\u002Fnix\u002Fstore\": No such file or directory",". The fix:",[416,486,488],{"className":418,"code":487,"language":420,"meta":421,"style":421},"sudo mkdir -p \u002Fnix\u002Fstore\nsudo chown root:nixbld \u002Fnix\u002Fstore\nsudo chmod 1775 \u002Fnix\u002Fstore        # sticky bit + group-writable for the build users\nsudo systemctl restart nix-daemon\nnix run nixpkgs#hello              # Hello, world!\n",[122,489,490,495,500,505,510],{"__ignoreMap":421},[197,491,492],{"class":426,"line":427},[197,493,494],{},"sudo mkdir -p \u002Fnix\u002Fstore\n",[197,496,497],{"class":426,"line":433},[197,498,499],{},"sudo chown root:nixbld \u002Fnix\u002Fstore\n",[197,501,502],{"class":426,"line":439},[197,503,504],{},"sudo chmod 1775 \u002Fnix\u002Fstore        # sticky bit + group-writable for the build users\n",[197,506,507],{"class":426,"line":445},[197,508,509],{},"sudo systemctl restart nix-daemon\n",[197,511,513],{"class":426,"line":512},5,[197,514,515],{},"nix run nixpkgs#hello              # Hello, world!\n",[92,517,519],{"id":518},"part-2-the-config","Part 2: The config",[10,521,522],{},"The final layout:",[416,524,528],{"className":525,"code":526,"language":527,"meta":421,"style":421},"language-plain shiki shiki-themes github-light github-dark","nixos-config\u002F\n├── flake.nix\n├── flake.lock\n├── hosts\u002F\n│   └── khakcsar\u002F\n│       ├── default.nix                 # the system\n│       ├── disko.nix                   # the disk layout\n│       └── hardware-configuration.nix  # generated during install\n└── services\u002F\n    └── caddy.nix\n","plain",[122,529,530,535,540,545,550,555,561,567,573,579],{"__ignoreMap":421},[197,531,532],{"class":426,"line":427},[197,533,534],{},"nixos-config\u002F\n",[197,536,537],{"class":426,"line":433},[197,538,539],{},"├── flake.nix\n",[197,541,542],{"class":426,"line":439},[197,543,544],{},"├── flake.lock\n",[197,546,547],{"class":426,"line":445},[197,548,549],{},"├── hosts\u002F\n",[197,551,552],{"class":426,"line":512},[197,553,554],{},"│   └── khakcsar\u002F\n",[197,556,558],{"class":426,"line":557},6,[197,559,560],{},"│       ├── default.nix                 # the system\n",[197,562,564],{"class":426,"line":563},7,[197,565,566],{},"│       ├── disko.nix                   # the disk layout\n",[197,568,570],{"class":426,"line":569},8,[197,571,572],{},"│       └── hardware-configuration.nix  # generated during install\n",[197,574,576],{"class":426,"line":575},9,[197,577,578],{},"└── services\u002F\n",[197,580,582],{"class":426,"line":581},10,[197,583,584],{},"    └── caddy.nix\n",[191,586,587],{},[10,588,589,592],{},[28,590,591],{},"A heads-up before you copy this:"," I'm new to Nix, and I don't think this is the best way to structure a NixOS config. It's simply the layout that made sense to me while learning. I also split it into a public and a private repo which is also not a standard afaik. I don't know yet what the usual approach looks like so expect the folders and repos to change as I learn more.",[10,594,595,598,599,602],{},[28,596,597],{},"Rule number one of flakes: Nix only sees files tracked by git."," Create a file, forget ",[122,600,601],{},"git add",", and Nix acts like the file doesn't exist. Edits to files that are already tracked are picked up without adding them again. I ended up forgetting this several times.",[173,604,606],{"id":605},"flakenix","flake.nix",[416,608,612],{"className":609,"code":610,"language":611,"meta":421,"style":421},"language-nix shiki shiki-themes github-light github-dark","{\n  description = \"khakcsar - NixOS on Oracle Cloud A1\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-26.05\";\n\n    disko.url = \"github:nix-community\u002Fdisko\";\n    disko.inputs.nixpkgs.follows = \"nixpkgs\";   # don't fetch a second nixpkgs\n  };\n\n  outputs = { self, nixpkgs, disko, ... }: {\n    # The base system as a module, so other flakes can import it (see below)\n    nixosModules.khakcsar = {\n      imports = [ disko.nixosModules.disko .\u002Fhosts\u002Fkhakcsar ];\n    };\n\n    nixosConfigurations.khakcsar = nixpkgs.lib.nixosSystem {\n      system = \"aarch64-linux\";\n      modules = [ self.nixosModules.khakcsar ];\n    };\n  };\n}\n","nix",[122,613,614,619,624,630,635,640,644,649,654,659,663,669,675,681,687,693,698,704,710,716,721,726],{"__ignoreMap":421},[197,615,616],{"class":426,"line":427},[197,617,618],{},"{\n",[197,620,621],{"class":426,"line":433},[197,622,623],{},"  description = \"khakcsar - NixOS on Oracle Cloud A1\";\n",[197,625,626],{"class":426,"line":439},[197,627,629],{"emptyLinePlaceholder":628},true,"\n",[197,631,632],{"class":426,"line":445},[197,633,634],{},"  inputs = {\n",[197,636,637],{"class":426,"line":512},[197,638,639],{},"    nixpkgs.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-26.05\";\n",[197,641,642],{"class":426,"line":557},[197,643,629],{"emptyLinePlaceholder":628},[197,645,646],{"class":426,"line":563},[197,647,648],{},"    disko.url = \"github:nix-community\u002Fdisko\";\n",[197,650,651],{"class":426,"line":569},[197,652,653],{},"    disko.inputs.nixpkgs.follows = \"nixpkgs\";   # don't fetch a second nixpkgs\n",[197,655,656],{"class":426,"line":575},[197,657,658],{},"  };\n",[197,660,661],{"class":426,"line":581},[197,662,629],{"emptyLinePlaceholder":628},[197,664,666],{"class":426,"line":665},11,[197,667,668],{},"  outputs = { self, nixpkgs, disko, ... }: {\n",[197,670,672],{"class":426,"line":671},12,[197,673,674],{},"    # The base system as a module, so other flakes can import it (see below)\n",[197,676,678],{"class":426,"line":677},13,[197,679,680],{},"    nixosModules.khakcsar = {\n",[197,682,684],{"class":426,"line":683},14,[197,685,686],{},"      imports = [ disko.nixosModules.disko .\u002Fhosts\u002Fkhakcsar ];\n",[197,688,690],{"class":426,"line":689},15,[197,691,692],{},"    };\n",[197,694,696],{"class":426,"line":695},16,[197,697,629],{"emptyLinePlaceholder":628},[197,699,701],{"class":426,"line":700},17,[197,702,703],{},"    nixosConfigurations.khakcsar = nixpkgs.lib.nixosSystem {\n",[197,705,707],{"class":426,"line":706},18,[197,708,709],{},"      system = \"aarch64-linux\";\n",[197,711,713],{"class":426,"line":712},19,[197,714,715],{},"      modules = [ self.nixosModules.khakcsar ];\n",[197,717,719],{"class":426,"line":718},20,[197,720,692],{},[197,722,724],{"class":426,"line":723},21,[197,725,658],{},[197,727,729],{"class":426,"line":728},22,[197,730,731],{},"}\n",[191,733,734],{},[10,735,736,739,740,743,744,747,748,751],{},[28,737,738],{},"Bug:"," my first version said ",[122,741,742],{},"nixosConfiguration"," (no ",[28,745,746],{},"s","). Flake outputs have no schema, so nothing complains until a tool goes looking for ",[122,749,750],{},"nixosConfigurations.khakcsar"," and can't find it. Beware of the typos.",[10,753,754],{},[67,755],{"alt":756,"height":757,"src":758,"width":72,"title":759},"An \"Only 1% of devs can spot the difference\" meme: two flake.nix snippets side by side, one with nixosConfiguration and one with nixosConfigurations, the missing s circled in red. Nix raises no error at first, then nixos-anywhere fails because the flake does not provide nixosConfigurations.khakcsar. The punchline: it's literally one letter",488,"\u002Fuploads\u002Fblog\u002Fnixos-typo.webp","Roses are red. I hate typos. Poetry's hard. NixOS",[173,761,763],{"id":762},"diskonix","disko.nix",[416,765,767],{"className":609,"code":766,"language":611,"meta":421,"style":421},"{\n  disko.devices.disk.main = {\n    type = \"disk\";\n    device = \"\u002Fdev\u002Fsda\";\n    content = {\n      type = \"gpt\";\n      partitions = {\n        ESP = {\n          size = \"512M\";\n          type = \"EF00\";\n          content = {\n            type = \"filesystem\";\n            format = \"vfat\";\n            mountpoint = \"\u002Fboot\";\n            mountOptions = [ \"umask=0077\" ];\n          };\n        };\n        root = {\n          size = \"100%\";\n          content = { type = \"filesystem\"; format = \"ext4\"; mountpoint = \"\u002F\"; };\n        };\n      };\n    };\n  };\n}\n",[122,768,769,773,778,783,788,793,798,803,808,813,818,823,828,833,838,843,848,853,858,863,868,872,877,882,887],{"__ignoreMap":421},[197,770,771],{"class":426,"line":427},[197,772,618],{},[197,774,775],{"class":426,"line":433},[197,776,777],{},"  disko.devices.disk.main = {\n",[197,779,780],{"class":426,"line":439},[197,781,782],{},"    type = \"disk\";\n",[197,784,785],{"class":426,"line":445},[197,786,787],{},"    device = \"\u002Fdev\u002Fsda\";\n",[197,789,790],{"class":426,"line":512},[197,791,792],{},"    content = {\n",[197,794,795],{"class":426,"line":557},[197,796,797],{},"      type = \"gpt\";\n",[197,799,800],{"class":426,"line":563},[197,801,802],{},"      partitions = {\n",[197,804,805],{"class":426,"line":569},[197,806,807],{},"        ESP = {\n",[197,809,810],{"class":426,"line":575},[197,811,812],{},"          size = \"512M\";\n",[197,814,815],{"class":426,"line":581},[197,816,817],{},"          type = \"EF00\";\n",[197,819,820],{"class":426,"line":665},[197,821,822],{},"          content = {\n",[197,824,825],{"class":426,"line":671},[197,826,827],{},"            type = \"filesystem\";\n",[197,829,830],{"class":426,"line":677},[197,831,832],{},"            format = \"vfat\";\n",[197,834,835],{"class":426,"line":683},[197,836,837],{},"            mountpoint = \"\u002Fboot\";\n",[197,839,840],{"class":426,"line":689},[197,841,842],{},"            mountOptions = [ \"umask=0077\" ];\n",[197,844,845],{"class":426,"line":695},[197,846,847],{},"          };\n",[197,849,850],{"class":426,"line":700},[197,851,852],{},"        };\n",[197,854,855],{"class":426,"line":706},[197,856,857],{},"        root = {\n",[197,859,860],{"class":426,"line":712},[197,861,862],{},"          size = \"100%\";\n",[197,864,865],{"class":426,"line":718},[197,866,867],{},"          content = { type = \"filesystem\"; format = \"ext4\"; mountpoint = \"\u002F\"; };\n",[197,869,870],{"class":426,"line":723},[197,871,852],{},[197,873,874],{"class":426,"line":728},[197,875,876],{},"      };\n",[197,878,880],{"class":426,"line":879},23,[197,881,692],{},[197,883,885],{"class":426,"line":884},24,[197,886,658],{},[197,888,890],{"class":426,"line":889},25,[197,891,731],{},[10,893,894,895,898,899,902,903,906],{},"The 512 MB ESP matters. A ",[28,896,897],{},"generation"," can be considered a version of your system, and you can roll back to a previous one if needed. ",[28,900,901],{},"systemd-boot"," keeps a kernel and initrd ",[28,904,905],{},"per generation"," in the ESP (about 50 to 60 MB each on ARM), and generations that use the same kernel share one copy. Ubuntu's 99 MB EFI partition would fill up fast. There's no swap partition. zram handles that.",[173,908,910],{"id":909},"defaultnix","default.nix",[416,912,914],{"className":609,"code":913,"language":611,"meta":421,"style":421},"{ modulesPath, pkgs, ... }:\n\nlet\n  sshKey = \"ssh-ed25519 AAAA... you@laptop\";\nin\n{\n  imports = [\n    (modulesPath + \"\u002Fprofiles\u002Fqemu-guest.nix\")   # virtio drivers: without this, no \u002Fdev\u002Fsda at boot\n    .\u002Fdisko.nix\n    .\u002Fhardware-configuration.nix\n    ..\u002F..\u002Fservices\u002Fcaddy.nix\n  ];\n\n  # Boot\n  boot.loader.systemd-boot.enable = true;\n  boot.loader.systemd-boot.configurationLimit = 10;     # protects the 512M ESP\n  boot.loader.efi.canTouchEfiVariables = true;\n  boot.kernelParams = [ \"console=ttyAMA0\" \"console=tty1\" ];  # ttyAMA0 = the ARM serial port OCI's console reads\n\n  # Network\n  networking.hostName = \"khakcsar\";\n  networking.useDHCP = true;\n  networking.firewall = {\n    enable = true;\n    allowedTCPPorts = [ 22 80 443 ];\n  };\n  time.timeZone = \"UTC\";\n\n  # SSH: keys only, no root\n  services.openssh = {\n    enable = true;\n    settings = {\n      PasswordAuthentication = false;\n      KbdInteractiveAuthentication = false;\n      PermitRootLogin = \"no\";\n    };\n  };\n\n  # Users: fully declarative\n  users.users.khakcsar = {\n    isNormalUser = true;\n    extraGroups = [ \"wheel\" ];\n    hashedPasswordFile = \"\u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash\";   # serial console only (see below)\n    openssh.authorizedKeys.keys = [ sshKey ];\n  };\n  users.mutableUsers = false;\n  security.sudo.wheelNeedsPassword = false;\n\n  # Nix itself\n  nix.settings = {\n    experimental-features = [ \"nix-command\" \"flakes\" ];\n    trusted-users = [ \"@wheel\" ];\n  };\n  nix.gc = { automatic = true; dates = \"weekly\"; options = \"--delete-older-than 14d\"; };\n  nix.optimise.automatic = true;\n\n  zramSwap.enable = true;\n\n  environment.systemPackages = with pkgs; [ git vim htop btop curl tree ncdu ];\n\n  system.stateVersion = \"26.05\";   # the release you FIRST installed. Never change it.\n}\n",[122,915,916,921,925,930,935,940,944,949,954,959,964,969,974,978,983,988,993,998,1003,1007,1012,1017,1022,1027,1032,1037,1042,1048,1053,1059,1065,1070,1076,1082,1088,1094,1099,1104,1109,1115,1121,1127,1133,1139,1145,1150,1156,1162,1167,1173,1179,1185,1191,1196,1202,1208,1213,1219,1224,1230,1235,1241],{"__ignoreMap":421},[197,917,918],{"class":426,"line":427},[197,919,920],{},"{ modulesPath, pkgs, ... }:\n",[197,922,923],{"class":426,"line":433},[197,924,629],{"emptyLinePlaceholder":628},[197,926,927],{"class":426,"line":439},[197,928,929],{},"let\n",[197,931,932],{"class":426,"line":445},[197,933,934],{},"  sshKey = \"ssh-ed25519 AAAA... you@laptop\";\n",[197,936,937],{"class":426,"line":512},[197,938,939],{},"in\n",[197,941,942],{"class":426,"line":557},[197,943,618],{},[197,945,946],{"class":426,"line":563},[197,947,948],{},"  imports = [\n",[197,950,951],{"class":426,"line":569},[197,952,953],{},"    (modulesPath + \"\u002Fprofiles\u002Fqemu-guest.nix\")   # virtio drivers: without this, no \u002Fdev\u002Fsda at boot\n",[197,955,956],{"class":426,"line":575},[197,957,958],{},"    .\u002Fdisko.nix\n",[197,960,961],{"class":426,"line":581},[197,962,963],{},"    .\u002Fhardware-configuration.nix\n",[197,965,966],{"class":426,"line":665},[197,967,968],{},"    ..\u002F..\u002Fservices\u002Fcaddy.nix\n",[197,970,971],{"class":426,"line":671},[197,972,973],{},"  ];\n",[197,975,976],{"class":426,"line":677},[197,977,629],{"emptyLinePlaceholder":628},[197,979,980],{"class":426,"line":683},[197,981,982],{},"  # Boot\n",[197,984,985],{"class":426,"line":689},[197,986,987],{},"  boot.loader.systemd-boot.enable = true;\n",[197,989,990],{"class":426,"line":695},[197,991,992],{},"  boot.loader.systemd-boot.configurationLimit = 10;     # protects the 512M ESP\n",[197,994,995],{"class":426,"line":700},[197,996,997],{},"  boot.loader.efi.canTouchEfiVariables = true;\n",[197,999,1000],{"class":426,"line":706},[197,1001,1002],{},"  boot.kernelParams = [ \"console=ttyAMA0\" \"console=tty1\" ];  # ttyAMA0 = the ARM serial port OCI's console reads\n",[197,1004,1005],{"class":426,"line":712},[197,1006,629],{"emptyLinePlaceholder":628},[197,1008,1009],{"class":426,"line":718},[197,1010,1011],{},"  # Network\n",[197,1013,1014],{"class":426,"line":723},[197,1015,1016],{},"  networking.hostName = \"khakcsar\";\n",[197,1018,1019],{"class":426,"line":728},[197,1020,1021],{},"  networking.useDHCP = true;\n",[197,1023,1024],{"class":426,"line":879},[197,1025,1026],{},"  networking.firewall = {\n",[197,1028,1029],{"class":426,"line":884},[197,1030,1031],{},"    enable = true;\n",[197,1033,1034],{"class":426,"line":889},[197,1035,1036],{},"    allowedTCPPorts = [ 22 80 443 ];\n",[197,1038,1040],{"class":426,"line":1039},26,[197,1041,658],{},[197,1043,1045],{"class":426,"line":1044},27,[197,1046,1047],{},"  time.timeZone = \"UTC\";\n",[197,1049,1051],{"class":426,"line":1050},28,[197,1052,629],{"emptyLinePlaceholder":628},[197,1054,1056],{"class":426,"line":1055},29,[197,1057,1058],{},"  # SSH: keys only, no root\n",[197,1060,1062],{"class":426,"line":1061},30,[197,1063,1064],{},"  services.openssh = {\n",[197,1066,1068],{"class":426,"line":1067},31,[197,1069,1031],{},[197,1071,1073],{"class":426,"line":1072},32,[197,1074,1075],{},"    settings = {\n",[197,1077,1079],{"class":426,"line":1078},33,[197,1080,1081],{},"      PasswordAuthentication = false;\n",[197,1083,1085],{"class":426,"line":1084},34,[197,1086,1087],{},"      KbdInteractiveAuthentication = false;\n",[197,1089,1091],{"class":426,"line":1090},35,[197,1092,1093],{},"      PermitRootLogin = \"no\";\n",[197,1095,1097],{"class":426,"line":1096},36,[197,1098,692],{},[197,1100,1102],{"class":426,"line":1101},37,[197,1103,658],{},[197,1105,1107],{"class":426,"line":1106},38,[197,1108,629],{"emptyLinePlaceholder":628},[197,1110,1112],{"class":426,"line":1111},39,[197,1113,1114],{},"  # Users: fully declarative\n",[197,1116,1118],{"class":426,"line":1117},40,[197,1119,1120],{},"  users.users.khakcsar = {\n",[197,1122,1124],{"class":426,"line":1123},41,[197,1125,1126],{},"    isNormalUser = true;\n",[197,1128,1130],{"class":426,"line":1129},42,[197,1131,1132],{},"    extraGroups = [ \"wheel\" ];\n",[197,1134,1136],{"class":426,"line":1135},43,[197,1137,1138],{},"    hashedPasswordFile = \"\u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash\";   # serial console only (see below)\n",[197,1140,1142],{"class":426,"line":1141},44,[197,1143,1144],{},"    openssh.authorizedKeys.keys = [ sshKey ];\n",[197,1146,1148],{"class":426,"line":1147},45,[197,1149,658],{},[197,1151,1153],{"class":426,"line":1152},46,[197,1154,1155],{},"  users.mutableUsers = false;\n",[197,1157,1159],{"class":426,"line":1158},47,[197,1160,1161],{},"  security.sudo.wheelNeedsPassword = false;\n",[197,1163,1165],{"class":426,"line":1164},48,[197,1166,629],{"emptyLinePlaceholder":628},[197,1168,1170],{"class":426,"line":1169},49,[197,1171,1172],{},"  # Nix itself\n",[197,1174,1176],{"class":426,"line":1175},50,[197,1177,1178],{},"  nix.settings = {\n",[197,1180,1182],{"class":426,"line":1181},51,[197,1183,1184],{},"    experimental-features = [ \"nix-command\" \"flakes\" ];\n",[197,1186,1188],{"class":426,"line":1187},52,[197,1189,1190],{},"    trusted-users = [ \"@wheel\" ];\n",[197,1192,1194],{"class":426,"line":1193},53,[197,1195,658],{},[197,1197,1199],{"class":426,"line":1198},54,[197,1200,1201],{},"  nix.gc = { automatic = true; dates = \"weekly\"; options = \"--delete-older-than 14d\"; };\n",[197,1203,1205],{"class":426,"line":1204},55,[197,1206,1207],{},"  nix.optimise.automatic = true;\n",[197,1209,1211],{"class":426,"line":1210},56,[197,1212,629],{"emptyLinePlaceholder":628},[197,1214,1216],{"class":426,"line":1215},57,[197,1217,1218],{},"  zramSwap.enable = true;\n",[197,1220,1222],{"class":426,"line":1221},58,[197,1223,629],{"emptyLinePlaceholder":628},[197,1225,1227],{"class":426,"line":1226},59,[197,1228,1229],{},"  environment.systemPackages = with pkgs; [ git vim htop btop curl tree ncdu ];\n",[197,1231,1233],{"class":426,"line":1232},60,[197,1234,629],{"emptyLinePlaceholder":628},[197,1236,1238],{"class":426,"line":1237},61,[197,1239,1240],{},"  system.stateVersion = \"26.05\";   # the release you FIRST installed. Never change it.\n",[197,1242,1244],{"class":426,"line":1243},62,[197,1245,731],{},[10,1247,1248,1249,1252,1253,1256],{},"The lines specific to OCI are ",[122,1250,1251],{},"qemu-guest.nix"," (virtio disk and network drivers) and ",[122,1254,1255],{},"console=ttyAMA0",". Without the second one the serial console you tested earlier stays blank on NixOS.",[10,1258,1259,1260,1263,1264,1267],{},"For the very first install, ",[122,1261,1262],{},"hashedPasswordFile"," doesn't exist yet because the server hasn't been installed. So for the first install I put the hash straight into the config with ",[122,1265,1266],{},"hashedPassword",", and moved it out later.",[10,1269,1270,1273],{},[28,1271,1272],{},"Generating the hash."," On the laptop, run:",[416,1275,1277],{"className":418,"code":1276,"language":420,"meta":421,"style":421},"nix shell nixpkgs#mkpasswd -c mkpasswd -m yescrypt\n",[122,1278,1279],{"__ignoreMap":421},[197,1280,1281],{"class":426,"line":427},[197,1282,1276],{},[10,1284,1285,1286,1289,1290,1293,1294,1297,1298,1301],{},"It asks for the password twice and prints something like ",[122,1287,1288],{},"$y$j9T$...."," That's a ",[28,1291,1292],{},"hash",", not the password: it can check a password but can't be turned back into one. ",[28,1295,1296],{},"yescrypt"," is what modern Linux uses for ",[122,1299,1300],{},"\u002Fetc\u002Fshadow"," and it's deliberately slow to crack.",[10,1303,1304],{},"Then paste the output into the config:",[416,1306,1308],{"className":609,"code":1307,"language":611,"meta":421,"style":421},"hashedPassword = \"$y$j9T$...\";   # first install only, see \"Sharing the config\"\n",[122,1309,1310],{"__ignoreMap":421},[197,1311,1312],{"class":426,"line":427},[197,1313,1307],{},[10,1315,1316,1317,1320,1321,1324],{},"The ",[122,1318,1319],{},"$"," signs are fine inside a Nix string. Nix only treats ",[122,1322,1323],{},"${"," as special and the hash never contains it.",[10,1326,1327,1330],{},[28,1328,1329],{},"Test it before you need it."," After the install, log in on the serial console with this password once. That's the whole point of it. The moment SSH breaks, this is the only way in.",[10,1332,1333,1334,1337],{},"Even a hash shouldn't be public. Anyone who has it can try guessing passwords offline for as long as they like. That's why I moved it into ",[122,1335,1336],{},"hashedPasswordFile ","outside the repo. More on that in the section about sharing the config.",[173,1339,1341],{"id":1340},"check-before-touching-the-server","Check before touching the server",[416,1343,1345],{"className":418,"code":1344,"language":420,"meta":421,"style":421},"echo \"{ }\" > hosts\u002Fkhakcsar\u002Fhardware-configuration.nix   # placeholder; an empty *file* is a syntax error\ngit add -A\nnix eval .#nixosConfigurations.khakcsar.config.system.build.toplevel.drvPath\n",[122,1346,1347,1352,1357],{"__ignoreMap":421},[197,1348,1349],{"class":426,"line":427},[197,1350,1351],{},"echo \"{ }\" > hosts\u002Fkhakcsar\u002Fhardware-configuration.nix   # placeholder; an empty *file* is a syntax error\n",[197,1353,1354],{"class":426,"line":433},[197,1355,1356],{},"git add -A\n",[197,1358,1359],{"class":426,"line":439},[197,1360,1361],{},"nix eval .#nixosConfigurations.khakcsar.config.system.build.toplevel.drvPath\n",[10,1363,1364,1367,1368,1371,1372,1375,1376],{},[122,1365,1366],{},"nix eval"," merges every module and type-checks every option ",[28,1369,1370],{},"without building anything."," So it works fine on an x86 laptop for an ARM target. It's great at catching typos. My ",[122,1373,1374],{},"user.users.khakcsar"," failed right away with ",[377,1377,1378,1379,1382],{},"\"The option ",[122,1380,1381],{},"user"," does not exist.\"",[92,1384,1386],{"id":1385},"part-3-the-install","Part 3: The install",[416,1388,1390],{"className":418,"code":1389,"language":420,"meta":421,"style":421},"nix run github:nix-community\u002Fnixos-anywhere -- \\\n  --flake .#khakcsar \\\n  --kexec https:\u002F\u002Fgithub.com\u002Fnix-community\u002Fnixos-images\u002Freleases\u002Fdownload\u002Fnixos-26.05\u002Fnixos-kexec-installer-noninteractive-aarch64-linux.tar.gz \\\n  --generate-hardware-config nixos-generate-config .\u002Fhosts\u002Fkhakcsar\u002Fhardware-configuration.nix \\\n  --build-on remote \\\n  -i ~\u002F.ssh\u002F\u003Cyour-key> \\\n  ubuntu@\u003Cinstance-ip>\n",[122,1391,1392,1397,1402,1407,1412,1417,1422],{"__ignoreMap":421},[197,1393,1394],{"class":426,"line":427},[197,1395,1396],{},"nix run github:nix-community\u002Fnixos-anywhere -- \\\n",[197,1398,1399],{"class":426,"line":433},[197,1400,1401],{},"  --flake .#khakcsar \\\n",[197,1403,1404],{"class":426,"line":439},[197,1405,1406],{},"  --kexec https:\u002F\u002Fgithub.com\u002Fnix-community\u002Fnixos-images\u002Freleases\u002Fdownload\u002Fnixos-26.05\u002Fnixos-kexec-installer-noninteractive-aarch64-linux.tar.gz \\\n",[197,1408,1409],{"class":426,"line":445},[197,1410,1411],{},"  --generate-hardware-config nixos-generate-config .\u002Fhosts\u002Fkhakcsar\u002Fhardware-configuration.nix \\\n",[197,1413,1414],{"class":426,"line":512},[197,1415,1416],{},"  --build-on remote \\\n",[197,1418,1419],{"class":426,"line":557},[197,1420,1421],{},"  -i ~\u002F.ssh\u002F\u003Cyour-key> \\\n",[197,1423,1424],{"class":426,"line":563},[197,1425,1426],{},"  ubuntu@\u003Cinstance-ip>\n",[10,1428,1429,1430,1433,1434,1437,1438,1441],{},"nixos-anywhere SSH's into Ubuntu and uses ",[122,1431,1432],{},"kexec"," to swap the running kernel for a NixOS installer ",[28,1435,1436],{},"in RAM",". It then partitions the disk with disko, builds the system, installs it, and reboots. With ",[122,1439,1440],{},"--build-on remote",", the ARM server builds its own system so my x86 laptop never has to emulate ARM. Amazing right?",[10,1443,1444],{},[67,1445],{"alt":1446,"height":1447,"src":1448,"width":90},"The six steps of nixos-anywhere, from SSH into Ubuntu through kexec, hardware scan, disko partitioning, building on the server, and rebooting into NixOS, with the point of no return marked before disko",2284,"\u002Fuploads\u002Fblog\u002Fnixos-install.webp",[173,1450,1452],{"id":1451},"the-kexec-flag-my-first-attempt-hung","The --kexec flag: my first attempt hung",[10,1454,1455,1456,1459,1460,1463,1464,1467],{},"Without ",[122,1457,1458],{},"--kexec,"," nixos-anywhere used its default installer image (",[122,1461,1462],{},"nixos-installer-kexec-25.11pre-git","). It printed ",[122,1465,1466],{},"machine will boot into nixos in 6s...",", reconnected a few times, and then:",[416,1469,1471],{"className":525,"code":1470,"language":527,"meta":421,"style":421},"ssh: connect to host \u003Cinstance-ip> port 22: Connection timed out\n",[122,1472,1473],{"__ignoreMap":421},[197,1474,1475],{"class":426,"line":427},[197,1476,1470],{},[10,1478,1479],{},[67,1480],{"alt":1481,"height":1482,"src":1483,"width":72,"title":1484},"A T-rex labelled Oracle Cloud looms with its teeth bared behind a wide-eyed dog labelled My instance, who stares into the camera in panic",490,"\u002Fuploads\u002Fblog\u002Fnixos-trex.webp","I lowkey thought my instance was history",[10,1486,1487,1488,1491,1492,1495],{},"The serial console showed ",[28,1489,1490],{},"Ubuntu"," (its root partition ",[122,1493,1494],{},"sda1",", its machine-id), spamming errors:",[416,1497,1499],{"className":525,"code":1498,"language":527,"meta":421,"style":421},"I\u002FO error, dev sda, sector 4016664 op 0x1:(WRITE)\nAborting journal on device sda1-8.\nEXT4-fs (sda1): Remounting filesystem read-only\n",[122,1500,1501,1506,1511],{"__ignoreMap":421},[197,1502,1503],{"class":426,"line":427},[197,1504,1505],{},"I\u002FO error, dev sda, sector 4016664 op 0x1:(WRITE)\n",[197,1507,1508],{"class":426,"line":433},[197,1509,1510],{},"Aborting journal on device sda1-8.\n",[197,1512,1513],{"class":426,"line":439},[197,1514,1515],{},"EXT4-fs (sda1): Remounting filesystem read-only\n",[10,1517,1518],{},[67,1519],{"alt":1520,"height":169,"src":1521,"width":171},"Oracle Cloud reporting that the instance is unresponsive, with launch options showing paravirtualized volumes and Secure Boot disabled","\u002Fuploads\u002Fblog\u002Fnixos-shot-instance-unresponsive.webp",[10,1523,1524],{},"So kexec had loaded the new kernel and shut down Ubuntu's disk and network devices but the jump into the new kernel never happened. Ubuntu kept running with no disk and no network.",[10,1526,1527,1528,1531,1532,1535,1536,1539,1540,1543],{},"The good news: ",[28,1529,1530],{},"disko hadn't run yet"," so the disk was untouched. A ",[28,1533,1534],{},"force reboot"," (Actions → Reboot → ",[377,1537,1538],{},"Force",") brought Ubuntu back and calmed my inner soul. Passing the 26.05 image explicitly with ",[122,1541,1542],{},"--kexec"," worked on the first try. Secure Boot was off, so that wasn't the cause.",[10,1545,1546],{},"Other people have installed on OCI with the default image without trouble, so this may depend on the instance or the kernel. If yours hanged on the same step, now you know what to do.",[10,1548,1549],{},[28,1550,1551],{},"What a successful run looks like:",[33,1553,1554,1568,1571],{},[36,1555,1556,1557,1560,1561,1567],{},"After ",[122,1558,1559],{},"machine will boot into nixos in 6s…",", the console's kernel timestamps ",[28,1562,1563,1564],{},"restart at ",[122,1565,1566],{},"[ 0.000000]",". That means kexec worked.",[36,1569,1570],{},"The hardware config file gets generated, then disko wipes and partitions the disk (the point of no return), then the build and install run.",[36,1572,1573,1576,1577],{},[122,1574,1575],{},"installation finished!",", then ",[122,1578,1579],{},"### Done! ###",[10,1581,1582,1583,1586],{},"I landed in the UEFI boot menu at first, because I'd pressed a key in the console. ",[28,1584,1585],{},"Linux Boot Manager"," is systemd-boot.",[10,1588,1589],{},[67,1590],{"alt":1591,"height":1592,"src":1593,"width":1594},"The UEFI boot device menu listing Linux Boot Manager, the Oracle block volume, firmware setup, the EFI shell and PXE",520,"\u002Fuploads\u002Fblog\u002Fnixos-shot-uefi-boot-menu.webp",820,[10,1596,1597],{},[67,1598],{"alt":1599,"height":1600,"src":1601,"width":171},"The serial console welcoming NixOS 26.05 on aarch64 and a successful login as khakcsar",1025,"\u002Fuploads\u002Fblog\u002Fnixos-shot-first-boot.webp",[191,1603,1604],{},[10,1605,1606,1609,1610,1613,1614,1616],{},[28,1607,1608],{},"Careful:"," type the ",[28,1611,1612],{},"username"," at ",[122,1615,252],{},". I typed my password there and it showed up in plain text 💀. That's why I don't have an unedited screenshot of that moment.",[173,1618,1620],{"id":1619},"after-the-install","After the install",[10,1622,1623,1624,1627],{},"The server now has a new SSH host key so the laptop refuses to connect (",[377,1625,1626],{},"REMOTE HOST IDENTIFICATION HAS CHANGED","). Remove the old Ubuntu entry, then log in as the new user:",[416,1629,1631],{"className":418,"code":1630,"language":420,"meta":421,"style":421},"ssh-keygen -R \u003Cinstance-ip>\n# ~\u002F.ssh\u002Fconfig: User ubuntu → User khakcsar\nssh khakcsar\n",[122,1632,1633,1638,1643],{"__ignoreMap":421},[197,1634,1635],{"class":426,"line":427},[197,1636,1637],{},"ssh-keygen -R \u003Cinstance-ip>\n",[197,1639,1640],{"class":426,"line":433},[197,1641,1642],{},"# ~\u002F.ssh\u002Fconfig: User ubuntu → User khakcsar\n",[197,1644,1645],{"class":426,"line":439},[197,1646,1647],{},"ssh khakcsar\n",[10,1649,1650,1651,1654],{},"That's the server's key not yours. Your own key pair is untouched. If you want to be thorough, compare the fingerprint SSH shows you with ",[122,1652,1653],{},"ssh-keygen -lf \u002Fetc\u002Fssh\u002Fssh_host_ed25519_key.pub",", run over the serial console.",[416,1656,1660],{"className":1657,"code":1658,"language":1659,"meta":421,"style":421},"language-console shiki shiki-themes github-light github-dark","[khakcsar@khakcsar:~]$ nixos-version\n26.05.20261102.7dcdd87 (Yarara)\n[khakcsar@khakcsar:~]$ systemctl --failed\n0 loaded units listed.\n[khakcsar@khakcsar:~]$ df -h \u002F \u002Fboot\n\u002Fdev\u002Fsda2        46G  2.5G   41G   6% \u002F\n\u002Fdev\u002Fsda1       511M   87M  425M  17% \u002Fboot\n","console",[122,1661,1662,1667,1672,1677,1682,1687,1692],{"__ignoreMap":421},[197,1663,1664],{"class":426,"line":427},[197,1665,1666],{},"[khakcsar@khakcsar:~]$ nixos-version\n",[197,1668,1669],{"class":426,"line":433},[197,1670,1671],{},"26.05.20261102.7dcdd87 (Yarara)\n",[197,1673,1674],{"class":426,"line":439},[197,1675,1676],{},"[khakcsar@khakcsar:~]$ systemctl --failed\n",[197,1678,1679],{"class":426,"line":445},[197,1680,1681],{},"0 loaded units listed.\n",[197,1683,1684],{"class":426,"line":512},[197,1685,1686],{},"[khakcsar@khakcsar:~]$ df -h \u002F \u002Fboot\n",[197,1688,1689],{"class":426,"line":557},[197,1690,1691],{},"\u002Fdev\u002Fsda2        46G  2.5G   41G   6% \u002F\n",[197,1693,1694],{"class":426,"line":563},[197,1695,1696],{},"\u002Fdev\u002Fsda1       511M   87M  425M  17% \u002Fboot\n",[10,1698,1699],{},"A whole OS in 2.5 GB.",[92,1701,1703],{"id":1702},"part-4-deploying-changes-from-the-laptop","Part 4: Deploying changes from the laptop",[416,1705,1707],{"className":418,"code":1706,"language":420,"meta":421,"style":421},"nix run --inputs-from . nixpkgs#nixos-rebuild -- switch \\\n  --flake .#khakcsar --target-host khakcsar --build-host khakcsar --sudo\n",[122,1708,1709,1714],{"__ignoreMap":421},[197,1710,1711],{"class":426,"line":427},[197,1712,1713],{},"nix run --inputs-from . nixpkgs#nixos-rebuild -- switch \\\n",[197,1715,1716],{"class":426,"line":433},[197,1717,1718],{},"  --flake .#khakcsar --target-host khakcsar --build-host khakcsar --sudo\n",[97,1720,1721,1731],{},[100,1722,1723],{},[103,1724,1725,1728],{},[106,1726,1727],{},"Part",[106,1729,1730],{},"Why",[111,1732,1733,1747,1760,1770],{},[103,1734,1735,1740],{},[116,1736,1737],{},[122,1738,1739],{},"--inputs-from . nixpkgs#nixos-rebuild",[116,1741,1742,1743,1746],{},"Uses the ",[122,1744,1745],{},"nixos-rebuild"," from my locked nixpkgs. Nothing installed on Arch",[103,1748,1749,1754],{},[116,1750,1751],{},[122,1752,1753],{},"--target-host",[116,1755,1756,1759],{},[28,1757,1758],{},"Where to activate."," Without it, the laptop is the target. Missing it is dangerous",[103,1761,1762,1767],{},[116,1763,1764],{},[122,1765,1766],{},"--build-host",[116,1768,1769],{},"Build natively on the ARM server",[103,1771,1772,1777],{},[116,1773,1774],{},[122,1775,1776],{},"--sudo",[116,1778,1779],{},"Activate with sudo on the server",[10,1781,1782,1783,1786,1787,1790],{},"You may see ",[122,1784,1785],{},"warning: could not re-exec in a newer version of nixos-rebuild",". nixos-rebuild tries to restart as the copy inside the target config, which is an ARM binary the x86 laptop can't run. It's harmless; ",[122,1788,1789],{},"--no-reexec"," silences it.",[10,1792,1793,1796,1797,1800,1801,1804,1805,1808,1809,1812,1813,1816,1817,1820,1821,1824],{},[28,1794,1795],{},"The first deploy surprised me:"," I added a package ",[122,1798,1799],{},"tree"," to ",[122,1802,1803],{},"systemPackages",", deployed, and it was available in my ",[28,1806,1807],{},"already open SSH session",". ",[122,1810,1811],{},"PATH"," includes ",[122,1814,1815],{},"\u002Frun\u002Fcurrent-system\u002Fsw\u002Fbin",", and activation just flips the ",[122,1818,1819],{},"\u002Frun\u002Fcurrent-system"," symlink in one step. Only services whose config changed get restarted. ",[122,1822,1823],{},"sshd"," hadn't changed, so my session survived.",[173,1826,1828],{"id":1827},"rollback","Rollback",[416,1830,1832],{"className":418,"code":1831,"language":420,"meta":421,"style":421},"nix run --inputs-from . nixpkgs#nixos-rebuild -- switch --rollback --flake .#khakcsar --target-host khakcsar --sudo\n",[122,1833,1834],{"__ignoreMap":421},[197,1835,1836],{"class":426,"line":427},[197,1837,1831],{},[10,1839,1840,1841,1843],{},"A rollback builds nothing: the previous generation is already on the server. ",[122,1842,1799],{}," vanished, and deploying again brought it back.",[10,1845,1846,1847,1850],{},"Fun fact: deploying an identical config ",[28,1848,1849],{},"doesn't create a new generation",". The new system has the same store path as the newest generation, so Nix just reuses it.",[10,1852,1853],{},[67,1854],{"alt":1855,"height":1856,"src":1857,"width":90},"How deploy, test and rollback move the current and boot generations, and which of them build anything",1456,"\u002Fuploads\u002Fblog\u002Fnixos-generations.webp",[10,1859,1860],{},"My safety nets:",[33,1862,1863,1869,1872],{},[36,1864,1865,1866,204],{},"Bad deploy, but SSH still works: ",[122,1867,1868],{},"switch --rollback",[36,1870,1871],{},"Bad deploy that broke SSH or the network: serial console, reboot, and pick an older generation in the systemd-boot menu.",[36,1873,1874,1875,1880,1881,1884],{},"A risky firewall, SSH or network change: deploy with ",[28,1876,1877],{},[122,1878,1879],{},"test"," instead of ",[122,1882,1883],{},"switch",". It activates without becoming the boot default, so a reboot undoes it.",[173,1886,1888],{"id":1887},"a-justfile-so-i-never-forget-target-host","A justfile so I never forget --target-host",[10,1890,1891,1896,1897,1900,1901,1904,1905,1908],{},[17,1892,1895],{"href":1893,"rel":1894,"target":22},"https:\u002F\u002Fgithub.com\u002Fcasey\u002Fjust",[21],"just"," is a small command runner. You write named recipes in a file called ",[122,1898,1899],{},"justfile",", and ",[122,1902,1903],{},"just deploy"," runs the matching one. Think of it as a ",[122,1906,1907],{},"Makefile"," without the build-system baggage.",[10,1910,1911,1914],{},[28,1912,1913],{},"Installing it on the laptop."," Pick one:",[416,1916,1920],{"className":1917,"code":1918,"language":1919,"meta":421,"style":421},"language-bash shiki shiki-themes github-light github-dark","# With Nix, like everything else here (on older Nix the command is \"nix profile install\")\nnix profile add nixpkgs#just\n\n# Or with Arch's package manager\nsudo pacman -S just\n\n# Or without installing anything, once\nnix run nixpkgs#just -- deploy\n","bash",[122,1921,1922,1928,1943,1947,1952,1967,1971,1976],{"__ignoreMap":421},[197,1923,1924],{"class":426,"line":427},[197,1925,1927],{"class":1926},"sJ8bj","# With Nix, like everything else here (on older Nix the command is \"nix profile install\")\n",[197,1929,1930,1933,1937,1940],{"class":426,"line":433},[197,1931,611],{"class":1932},"sScJk",[197,1934,1936],{"class":1935},"sZZnC"," profile",[197,1938,1939],{"class":1935}," add",[197,1941,1942],{"class":1935}," nixpkgs#just\n",[197,1944,1945],{"class":426,"line":439},[197,1946,629],{"emptyLinePlaceholder":628},[197,1948,1949],{"class":426,"line":445},[197,1950,1951],{"class":1926},"# Or with Arch's package manager\n",[197,1953,1954,1957,1960,1964],{"class":426,"line":512},[197,1955,1956],{"class":1932},"sudo",[197,1958,1959],{"class":1935}," pacman",[197,1961,1963],{"class":1962},"sj4cs"," -S",[197,1965,1966],{"class":1935}," just\n",[197,1968,1969],{"class":426,"line":557},[197,1970,629],{"emptyLinePlaceholder":628},[197,1972,1973],{"class":426,"line":563},[197,1974,1975],{"class":1926},"# Or without installing anything, once\n",[197,1977,1978,1980,1983,1986,1989],{"class":426,"line":569},[197,1979,611],{"class":1932},[197,1981,1982],{"class":1935}," run",[197,1984,1985],{"class":1935}," nixpkgs#just",[197,1987,1988],{"class":1962}," --",[197,1990,1991],{"class":1935}," deploy\n",[10,1993,1994],{},"I installed it through pacman because Arch (btw)",[10,1996,1997,1998,2001,2002,2005,2006,2008],{},"If your shell says ",[122,1999,2000],{},"just: command not found"," after the Nix install, ",[122,2003,2004],{},"~\u002F.nix-profile\u002Fbin"," isn't on your ",[122,2007,1811],{}," yet. Open a new terminal, or add it to your shell's config.",[10,2010,2011,2014,2015,2017,2018,2020,2021,2024,2025,2027],{},[28,2012,2013],{},"Using it."," Save the file below as ",[122,2016,1899],{}," in the root of the config repo, next to ",[122,2019,606],{},". Then ",[122,2022,2023],{},"just --list"," shows every recipe with its comment, and ",[122,2026,1903],{}," runs one. Recipe lines must be indented, the same way in the whole file.",[416,2029,2032],{"className":2030,"code":2031,"language":1895,"meta":421,"style":421},"language-just shiki shiki-themes github-light github-dark","host := \"khakcsar\"\nrebuild := \"nix run --inputs-from . nixpkgs#nixos-rebuild -- --no-reexec\"\n\n# Build on the server and switch to it (+ make it the boot default)\ndeploy:\n    {{ rebuild }} switch --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n\n# Activate without making it the boot default: a reboot undoes it\ntest:\n    {{ rebuild }} test --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n\n# Back to the previous generation (nothing is built)\nrollback:\n    {{ rebuild }} switch --rollback --flake .#{{ host }} --target-host {{ host }} --sudo\n\n# Evaluate the whole config locally (catches typos, builds nothing)\ncheck:\n    nix eval .#nixosConfigurations.{{ host }}.config.system.build.toplevel.drvPath\n\n# List system generations on the server\ngenerations:\n    ssh {{ host }} sudo nix-env --list-generations -p \u002Fnix\u002Fvar\u002Fnix\u002Fprofiles\u002Fsystem\n\n# Update flake.lock\nupdate:\n    nix flake update\n",[122,2033,2034,2039,2044,2048,2053,2058,2063,2067,2072,2077,2082,2086,2091,2096,2101,2105,2110,2115,2120,2124,2129,2134,2139,2143,2148,2153],{"__ignoreMap":421},[197,2035,2036],{"class":426,"line":427},[197,2037,2038],{},"host := \"khakcsar\"\n",[197,2040,2041],{"class":426,"line":433},[197,2042,2043],{},"rebuild := \"nix run --inputs-from . nixpkgs#nixos-rebuild -- --no-reexec\"\n",[197,2045,2046],{"class":426,"line":439},[197,2047,629],{"emptyLinePlaceholder":628},[197,2049,2050],{"class":426,"line":445},[197,2051,2052],{},"# Build on the server and switch to it (+ make it the boot default)\n",[197,2054,2055],{"class":426,"line":512},[197,2056,2057],{},"deploy:\n",[197,2059,2060],{"class":426,"line":557},[197,2061,2062],{},"    {{ rebuild }} switch --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n",[197,2064,2065],{"class":426,"line":563},[197,2066,629],{"emptyLinePlaceholder":628},[197,2068,2069],{"class":426,"line":569},[197,2070,2071],{},"# Activate without making it the boot default: a reboot undoes it\n",[197,2073,2074],{"class":426,"line":575},[197,2075,2076],{},"test:\n",[197,2078,2079],{"class":426,"line":581},[197,2080,2081],{},"    {{ rebuild }} test --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n",[197,2083,2084],{"class":426,"line":665},[197,2085,629],{"emptyLinePlaceholder":628},[197,2087,2088],{"class":426,"line":671},[197,2089,2090],{},"# Back to the previous generation (nothing is built)\n",[197,2092,2093],{"class":426,"line":677},[197,2094,2095],{},"rollback:\n",[197,2097,2098],{"class":426,"line":683},[197,2099,2100],{},"    {{ rebuild }} switch --rollback --flake .#{{ host }} --target-host {{ host }} --sudo\n",[197,2102,2103],{"class":426,"line":689},[197,2104,629],{"emptyLinePlaceholder":628},[197,2106,2107],{"class":426,"line":695},[197,2108,2109],{},"# Evaluate the whole config locally (catches typos, builds nothing)\n",[197,2111,2112],{"class":426,"line":700},[197,2113,2114],{},"check:\n",[197,2116,2117],{"class":426,"line":706},[197,2118,2119],{},"    nix eval .#nixosConfigurations.{{ host }}.config.system.build.toplevel.drvPath\n",[197,2121,2122],{"class":426,"line":712},[197,2123,629],{"emptyLinePlaceholder":628},[197,2125,2126],{"class":426,"line":718},[197,2127,2128],{},"# List system generations on the server\n",[197,2130,2131],{"class":426,"line":723},[197,2132,2133],{},"generations:\n",[197,2135,2136],{"class":426,"line":728},[197,2137,2138],{},"    ssh {{ host }} sudo nix-env --list-generations -p \u002Fnix\u002Fvar\u002Fnix\u002Fprofiles\u002Fsystem\n",[197,2140,2141],{"class":426,"line":879},[197,2142,629],{"emptyLinePlaceholder":628},[197,2144,2145],{"class":426,"line":884},[197,2146,2147],{},"# Update flake.lock\n",[197,2149,2150],{"class":426,"line":889},[197,2151,2152],{},"update:\n",[197,2154,2155],{"class":426,"line":1039},[197,2156,2157],{},"    nix flake update\n",[10,2159,2160,2161],{},"My workflow is now: ",[28,2162,2163,2164,2167,2168,2167,2170,204],{},"edit, ",[122,2165,2166],{},"just check",", ",[122,2169,1903],{},[122,2171,2172],{},"git commit",[92,2174,2176],{"id":2175},"part-5-caddy-https-and-a-free-domain","Part 5: Caddy, HTTPS and a free domain",[10,2178,2179,2180,2183,2184,2189,2190,2193],{},"Two firewalls guard every port: ",[28,2181,2182],{},"OCI's Security List"," and ",[28,2185,2186],{},[122,2187,2188],{},"networking.firewall",". When something doesn't respond, ",[122,2191,2192],{},"curl"," tells you which layer to look at:",[97,2195,2196,2208],{},[100,2197,2198],{},[103,2199,2200,2205],{},[106,2201,2202,2204],{},[122,2203,2192],{}," says",[106,2206,2207],{},"Meaning",[111,2209,2210,2218,2228],{},[103,2211,2212,2215],{},[116,2213,2214],{},"Times out",[116,2216,2217],{},"Blocked, usually by the OCI Security List",[103,2219,2220,2225],{},[116,2221,2222],{},[122,2223,2224],{},"Connection refused",[116,2226,2227],{},"Got through, but nothing is listening",[103,2229,2230,2233],{},[116,2231,2232],{},"Certificate error",[116,2234,2235,2236],{},"Reached Caddy, but no certificate yet: check ",[122,2237,2238],{},"journalctl -u caddy",[10,2240,2241],{},[67,2242],{"alt":2243,"height":2244,"src":2245,"width":90},"A request to port 443 passing the visitor, Oracle's Security List, the VM firewall and Caddy, with what each curl error means",1558,"\u002Fuploads\u002Fblog\u002Fnixos-firewalls.webp",[10,2247,2248,2249,2254,2255,2258,2259,2262,2263,2266],{},"I didn't own a domain for testing, so I used ",[17,2250,2253],{"href":2251,"rel":2252,"target":22},"https:\u002F\u002Fsslip.io",[21],"sslip.io",": ",[122,2256,2257],{},"203-0-113-10.sslip.io"," resolves to ",[122,2260,2261],{},"203.0.113.10",", so you put your own IP in place of that one. ",[28,2264,2265],{},"Let's Encrypt"," issues real certificates for those names.",[10,2268,2269,2272],{},[122,2270,2271],{},"services\u002Fcaddy.nix",":",[416,2274,2276],{"className":609,"code":2275,"language":611,"meta":421,"style":421},"{ pkgs, ... }:\n\nlet\n  domain = \"203-0-113-10.sslip.io\";   # your IP with dashes\n\n  # The website is built as a package in the Nix store\n  site = pkgs.writeTextDir \"index.html\" ''\n    \u003C!doctype html>\n    \u003Chtml>\n      \u003Chead>\u003Cmeta charset=\"utf-8\">\u003Ctitle>khakcsar\u003C\u002Ftitle>\u003C\u002Fhead>\n      \u003Cbody>\u003Ch1>Hello from NixOS on Oracle Cloud 🗿\u003C\u002Fh1>\u003C\u002Fbody>\n    \u003C\u002Fhtml>\n  '';\nin\n{\n  services.caddy = {\n    enable = true;\n    virtualHosts.${domain}.extraConfig = ''\n      root * ${site}\n      file_server\n    '';\n  };\n}\n",[122,2277,2278,2283,2287,2291,2296,2300,2305,2310,2315,2320,2325,2330,2335,2340,2344,2348,2353,2357,2362,2367,2372,2377,2381],{"__ignoreMap":421},[197,2279,2280],{"class":426,"line":427},[197,2281,2282],{},"{ pkgs, ... }:\n",[197,2284,2285],{"class":426,"line":433},[197,2286,629],{"emptyLinePlaceholder":628},[197,2288,2289],{"class":426,"line":439},[197,2290,929],{},[197,2292,2293],{"class":426,"line":445},[197,2294,2295],{},"  domain = \"203-0-113-10.sslip.io\";   # your IP with dashes\n",[197,2297,2298],{"class":426,"line":512},[197,2299,629],{"emptyLinePlaceholder":628},[197,2301,2302],{"class":426,"line":557},[197,2303,2304],{},"  # The website is built as a package in the Nix store\n",[197,2306,2307],{"class":426,"line":563},[197,2308,2309],{},"  site = pkgs.writeTextDir \"index.html\" ''\n",[197,2311,2312],{"class":426,"line":569},[197,2313,2314],{},"    \u003C!doctype html>\n",[197,2316,2317],{"class":426,"line":575},[197,2318,2319],{},"    \u003Chtml>\n",[197,2321,2322],{"class":426,"line":581},[197,2323,2324],{},"      \u003Chead>\u003Cmeta charset=\"utf-8\">\u003Ctitle>khakcsar\u003C\u002Ftitle>\u003C\u002Fhead>\n",[197,2326,2327],{"class":426,"line":665},[197,2328,2329],{},"      \u003Cbody>\u003Ch1>Hello from NixOS on Oracle Cloud 🗿\u003C\u002Fh1>\u003C\u002Fbody>\n",[197,2331,2332],{"class":426,"line":671},[197,2333,2334],{},"    \u003C\u002Fhtml>\n",[197,2336,2337],{"class":426,"line":677},[197,2338,2339],{},"  '';\n",[197,2341,2342],{"class":426,"line":683},[197,2343,939],{},[197,2345,2346],{"class":426,"line":689},[197,2347,618],{},[197,2349,2350],{"class":426,"line":695},[197,2351,2352],{},"  services.caddy = {\n",[197,2354,2355],{"class":426,"line":700},[197,2356,1031],{},[197,2358,2359],{"class":426,"line":706},[197,2360,2361],{},"    virtualHosts.${domain}.extraConfig = ''\n",[197,2363,2364],{"class":426,"line":712},[197,2365,2366],{},"      root * ${site}\n",[197,2368,2369],{"class":426,"line":718},[197,2370,2371],{},"      file_server\n",[197,2373,2374],{"class":426,"line":723},[197,2375,2376],{},"    '';\n",[197,2378,2379],{"class":426,"line":728},[197,2380,658],{},[197,2382,2383],{"class":426,"line":879},[197,2384,731],{},[10,2386,2387,2390,2391,2394,2395,2398],{},[122,2388,2389],{},"pkgs.writeTextDir"," puts the page in ",[122,2392,2393],{},"\u002Fnix\u002Fstore\u002FHASH-index.html\u002F",". Change the HTML and you get a new store path; roll back and the old page comes back. ",[28,2396,2397],{},"The website is part of the system generation."," Caddy gets a Let's Encrypt certificate, redirects HTTP to HTTPS and renews automatically, with zero TLS config.",[10,2400,2401],{},[67,2402],{"alt":2403,"height":2404,"src":2405,"width":2406,"title":2407},"The Caddy test page next to a terminal listing three system generations, and the config repo on the laptop",1142,"\u002Fuploads\u002Fblog\u002Fnixos-shot-generations-caddy.webp",1802,"NixOS serving a static page from my instance 🗿",[191,2409,2410],{},[10,2411,2412,2414,2415,2418,2419,2422,2423,2426],{},[28,2413,738],{}," my first ",[122,2416,2417],{},"caddy.nix"," started with ",[122,2420,2421],{},"{ pkgs, ... };",". It needs a ",[28,2424,2425],{},"colon",", because a module is a function. These typos will be the end of me.",[92,2428,2430],{"id":2429},"part-6-sharing-the-config-but-not-everything","Part 6: Sharing the config, but not everything",[10,2432,2433],{},"Someone on Reddit asked for my config, so I wanted it public. Two problems.",[173,2435,2437],{"id":2436},"_1-the-password-hash-was-in-every-commit","1. The password hash was in every commit",[10,2439,2440,2441,2272],{},"A yescrypt hash is safe to store but public means anyone can try to crack it offline. First I moved it into a file only root can read on the server. You can copy it straight out of ",[122,2442,1300],{},[416,2444,2446],{"className":1917,"code":2445,"language":1919,"meta":421,"style":421},"sudo sh -c 'umask 077; mkdir -p \u002Fvar\u002Flib\u002Fsecrets && getent shadow khakcsar | cut -d: -f2 > \u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash'\n",[122,2447,2448],{"__ignoreMap":421},[197,2449,2450,2452,2455,2458],{"class":426,"line":427},[197,2451,1956],{"class":1932},[197,2453,2454],{"class":1935}," sh",[197,2456,2457],{"class":1962}," -c",[197,2459,2460],{"class":1935}," 'umask 077; mkdir -p \u002Fvar\u002Flib\u002Fsecrets && getent shadow khakcsar | cut -d: -f2 > \u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash'\n",[10,2462,2463,2464,2467,2468,2471],{},"Then ",[122,2465,2466],{},"hashedPassword = \"…\";"," became ",[122,2469,2470],{},"hashedPasswordFile = \"\u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash\";",". Create the file before deploying, or the console password disappears.",[10,2473,2474,2475,2272],{},"Then I scrubbed it from history. I wanted to keep my commits, not squash everything into one, so I used ",[28,2476,2477],{},"git-filter-repo",[416,2479,2481],{"className":418,"code":2480,"language":420,"meta":421,"style":421},"cp -r nixos-config nixos-config-backup                 # backup first (it still contains the hash; never push it)\nprintf '%s\\n' 'regex:\\$y\\$[^\"]*==>REMOVED' > ~\u002Ffilter-rules.txt\ncd nixos-config\nnix shell nixpkgs#git-filter-repo -c git filter-repo --replace-text ~\u002Ffilter-rules.txt --force\ngit log --all -p | grep -c 'y\\$j9T'                    # 0\ngit cat-file --batch-all-objects --batch | grep -ca 'y\\$j9T'   # 0, including unreachable objects\n",[122,2482,2483,2488,2493,2498,2503,2508],{"__ignoreMap":421},[197,2484,2485],{"class":426,"line":427},[197,2486,2487],{},"cp -r nixos-config nixos-config-backup                 # backup first (it still contains the hash; never push it)\n",[197,2489,2490],{"class":426,"line":433},[197,2491,2492],{},"printf '%s\\n' 'regex:\\$y\\$[^\"]*==>REMOVED' > ~\u002Ffilter-rules.txt\n",[197,2494,2495],{"class":426,"line":439},[197,2496,2497],{},"cd nixos-config\n",[197,2499,2500],{"class":426,"line":445},[197,2501,2502],{},"nix shell nixpkgs#git-filter-repo -c git filter-repo --replace-text ~\u002Ffilter-rules.txt --force\n",[197,2504,2505],{"class":426,"line":512},[197,2506,2507],{},"git log --all -p | grep -c 'y\\$j9T'                    # 0\n",[197,2509,2510],{"class":426,"line":557},[197,2511,2512],{},"git cat-file --batch-all-objects --batch | grep -ca 'y\\$j9T'   # 0, including unreachable objects\n",[10,2514,2515,2516,2519,2520,2523],{},"Old commits now say ",[122,2517,2518],{},"hashedPassword = \"REMOVED\";",". Every commit ID changes, so do this ",[28,2521,2522],{},"before"," the first push.",[173,2525,2527],{"id":2526},"_2-i-didnt-want-to-publish-which-apps-i-run","2. I didn't want to publish which apps I run",[10,2529,2530,2531],{},"The solution: ",[28,2532,2533],{},"two flakes.",[216,2535,2536,2552],{},[36,2537,2538,2544,2545,2548,2549,2551],{},[28,2539,2540,2541,2272],{},"Public ",[122,2542,2543],{},"nixos-config"," the base system, exported as ",[122,2546,2547],{},"nixosModules.khakcsar"," (see ",[122,2550,606],{}," above).",[36,2553,2554,2560,2561,2563,2564,2567],{},[28,2555,2556,2557,2272],{},"Private ",[122,2558,2559],{},"nixos-infra"," imports the public flake and adds my apps. The ",[122,2562,1899],{}," lives here, and ",[28,2565,2566],{},"this is the only repo I deploy from.","\n(Again, I'm no expert so I'm not aware of the standard way to create this config so if you know a better way to handle this usecase then let me know via email or my socials)",[10,2569,2570],{},[67,2571],{"alt":2572,"height":2573,"src":2574,"width":90},"The public nixos-config flake exports the base module, the private nixos-infra flake imports it and adds the apps, and the server receives one system with the same derivation hash as before the split",1800,"\u002Fuploads\u002Fblog\u002Fnixos-flakes.webp",[416,2576,2578],{"className":609,"code":2577,"language":611,"meta":421,"style":421},"# nixos-infra\u002Fflake.nix (private)\n{\n  inputs = {\n    base.url = \"github:designbyfaizi\u002Fnixos-config\";\n    nixpkgs.follows = \"base\u002Fnixpkgs\";               # one nixpkgs, owned by the base\n    nixpkgs-unstable.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-unstable\";\n  };\n\n  outputs = { self, nixpkgs, nixpkgs-unstable, base, ... }: {\n    nixosConfigurations.khakcsar = nixpkgs.lib.nixosSystem {\n      system = \"aarch64-linux\";\n      specialArgs = {\n        pkgs-unstable = nixpkgs-unstable.legacyPackages.aarch64-linux;\n      };\n      modules = [\n        base.nixosModules.khakcsar\n        .\u002Fservices\u002Fmysite.nix\n      ];\n    };\n  };\n}\n",[122,2579,2580,2585,2589,2593,2598,2603,2608,2612,2616,2621,2625,2629,2634,2639,2643,2648,2653,2658,2663,2667,2671],{"__ignoreMap":421},[197,2581,2582],{"class":426,"line":427},[197,2583,2584],{},"# nixos-infra\u002Fflake.nix (private)\n",[197,2586,2587],{"class":426,"line":433},[197,2588,618],{},[197,2590,2591],{"class":426,"line":439},[197,2592,634],{},[197,2594,2595],{"class":426,"line":445},[197,2596,2597],{},"    base.url = \"github:designbyfaizi\u002Fnixos-config\";\n",[197,2599,2600],{"class":426,"line":512},[197,2601,2602],{},"    nixpkgs.follows = \"base\u002Fnixpkgs\";               # one nixpkgs, owned by the base\n",[197,2604,2605],{"class":426,"line":557},[197,2606,2607],{},"    nixpkgs-unstable.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-unstable\";\n",[197,2609,2610],{"class":426,"line":563},[197,2611,658],{},[197,2613,2614],{"class":426,"line":569},[197,2615,629],{"emptyLinePlaceholder":628},[197,2617,2618],{"class":426,"line":575},[197,2619,2620],{},"  outputs = { self, nixpkgs, nixpkgs-unstable, base, ... }: {\n",[197,2622,2623],{"class":426,"line":581},[197,2624,703],{},[197,2626,2627],{"class":426,"line":665},[197,2628,709],{},[197,2630,2631],{"class":426,"line":671},[197,2632,2633],{},"      specialArgs = {\n",[197,2635,2636],{"class":426,"line":677},[197,2637,2638],{},"        pkgs-unstable = nixpkgs-unstable.legacyPackages.aarch64-linux;\n",[197,2640,2641],{"class":426,"line":683},[197,2642,876],{},[197,2644,2645],{"class":426,"line":689},[197,2646,2647],{},"      modules = [\n",[197,2649,2650],{"class":426,"line":695},[197,2651,2652],{},"        base.nixosModules.khakcsar\n",[197,2654,2655],{"class":426,"line":700},[197,2656,2657],{},"        .\u002Fservices\u002Fmysite.nix\n",[197,2659,2660],{"class":426,"line":706},[197,2661,2662],{},"      ];\n",[197,2664,2665],{"class":426,"line":712},[197,2666,692],{},[197,2668,2669],{"class":426,"line":718},[197,2670,658],{},[197,2672,2673],{"class":426,"line":723},[197,2674,731],{},[10,2676,2677,2678,2681],{},"Changing the base means pushing the public repo, then running ",[122,2679,2680],{},"nix flake update base"," in the private one, then deploying.",[10,2683,2684,2687,2688,2690,2691,2697],{},[28,2685,2686],{},"My favourite trick from this whole project:"," after each refactor, I ran ",[122,2689,2166],{}," and confirmed the ",[28,2692,2693,2696],{},[122,2694,2695],{},".drv"," hash was identical",". Moving files or splitting flakes doesn't change what gets built, so a matching hash proves the refactor changed nothing, giving me an inner peace moment.",[10,2699,2700,2701,2704,2705,2710,2711,2714,2715,2718],{},"One more thing: if you're hiding hostnames for privacy, don't count on it. ",[28,2702,2703],{},"Every Let's Encrypt certificate is published"," in Certificate Transparency logs (",[17,2706,2709],{"href":2707,"rel":2708,"target":22},"https:\u002F\u002Fcrt.sh",[21],"crt.sh","). The private repo hides ",[377,2712,2713],{},"how"," things run, not ",[377,2716,2717],{},"that"," they exist.",[92,2720,2722],{"id":2721},"part-7-a-nuxt-and-bun-app-without-docker","Part 7: A Nuxt and Bun app without Docker",[10,2724,2725,2726,2729,2730,2733,2734,2737],{},"The app is a Nuxt 4 site: Nitro with ",[122,2727,2728],{},"preset: \"bun\"",", Nuxt Content (SQLite), ",[122,2731,2732],{},"@nuxt\u002Fimage"," (sharp), shadcn-vue, and ",[28,2735,2736],{},"Sveltia CMS",", which commits content edits straight to GitHub. Pages are prerendered, but a server still has to run for the API route and for resizing images on demand.",[10,2739,2740],{},"On Dokploy, it was a Dockerfile plus a webhook. Here's the NixOS version.",[173,2742,2744],{"id":2743},"dns-a-wildcard-for-test-apps","DNS: a wildcard for test apps",[10,2746,2747],{},"My primary domain was connected to my main vps, so I decided to create .nix subdomains for this server. In Cloudflare (DNS only, grey cloud):",[97,2749,2750,2763],{},[100,2751,2752],{},[103,2753,2754,2757,2760],{},[106,2755,2756],{},"Name",[106,2758,2759],{},"Type",[106,2761,2762],{},"Content",[111,2764,2765,2779],{},[103,2766,2767,2771,2774],{},[116,2768,2769],{},[122,2770,611],{},[116,2772,2773],{},"A",[116,2775,2776],{},[122,2777,2778],{},"\u003Cinstance-ip>",[103,2780,2781,2786,2788],{},[116,2782,2783],{},[122,2784,2785],{},"*.nix",[116,2787,2773],{},[116,2789,2790],{},[122,2791,2778],{},[10,2793,2794,2795,2798,2799,2802,2803,2806,2807,2809],{},"Now ",[122,2796,2797],{},"anything.nix.example.com"," reaches the server. I originally wanted ",[122,2800,2801],{},"*-nix.example.com",", but ",[28,2804,2805],{},"a DNS wildcard has to be a whole label",", so ",[122,2808,2785],{}," is the closest you can get. Caddy still gets one normal certificate per hostname you configure. You don't need a wildcard certificate.",[173,2811,2813],{"id":2812},"gotcha-1-the-bun-version","Gotcha 1: the Bun version",[10,2815,2816,2819],{},[122,2817,2818],{},"bun install --frozen-lockfile"," with the Bun in nixos-26.05 (1.3.13):",[416,2821,2823],{"className":525,"code":2822,"language":527,"meta":421,"style":421},"warn: Ignoring lockfile\nerror: lockfile had changes, but lockfile is frozen\n",[122,2824,2825,2830],{"__ignoreMap":421},[197,2826,2827],{"class":426,"line":427},[197,2828,2829],{},"warn: Ignoring lockfile\n",[197,2831,2832],{"class":426,"line":433},[197,2833,2834],{},"error: lockfile had changes, but lockfile is frozen\n",[10,2836,2837,2838,2841,2842,2845,2846,2849,2850,2853,2854,2857],{},"My ",[122,2839,2840],{},"bun.lock"," is in ",[28,2843,2844],{},"v2 format",", which ",[28,2847,2848],{},"Bun 1.4"," writes. nixos-unstable has 1.4.4, so the system stays on stable and I take ",[377,2851,2852],{},"just Bun"," from unstable. That's the ",[122,2855,2856],{},"pkgs-unstable"," in the private flake above.",[173,2859,2861],{"id":2860},"gotcha-2-prebuilt-native-modules","Gotcha 2: prebuilt native modules",[10,2863,2864,2867,2868,2871,2872,2875],{},[122,2865,2866],{},"sharp"," and Tailwind's ",[122,2869,2870],{},"oxide"," ship prebuilt ",[122,2873,2874],{},".node"," binaries:",[416,2877,2879],{"className":1657,"code":2878,"language":1659,"meta":421,"style":421},"$ readelf -d node_modules\u002F@img\u002Fsharp-linux-x64\u002Flib\u002F*.node | grep NEEDED\n (NEEDED)  Shared library: [libvips-cpp.so.8.18.7]\n (NEEDED)  Shared library: [libstdc++.so.6]\n (NEEDED)  Shared library: [libgcc_s.so.1]\n ...\n",[122,2880,2881,2886,2891,2896,2901],{"__ignoreMap":421},[197,2882,2883],{"class":426,"line":427},[197,2884,2885],{},"$ readelf -d node_modules\u002F@img\u002Fsharp-linux-x64\u002Flib\u002F*.node | grep NEEDED\n",[197,2887,2888],{"class":426,"line":433},[197,2889,2890],{}," (NEEDED)  Shared library: [libvips-cpp.so.8.18.7]\n",[197,2892,2893],{"class":426,"line":439},[197,2894,2895],{}," (NEEDED)  Shared library: [libstdc++.so.6]\n",[197,2897,2898],{"class":426,"line":445},[197,2899,2900],{}," (NEEDED)  Shared library: [libgcc_s.so.1]\n",[197,2902,2903],{"class":426,"line":512},[197,2904,2905],{}," ...\n",[10,2907,2908,2909,2912,2913,2916,2917,2920],{},"NixOS doesn't keep ",[122,2910,2911],{},"libstdc++"," in ",[122,2914,2915],{},"\u002Fusr\u002Flib",", so I point them at Nix's copy with ",[122,2918,2919],{},"LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]",". It's not elegant, but it works. More on that at the end.",[173,2922,2924],{"id":2923},"the-module-servicesmysitenix","The module: services\u002Fmysite.nix",[416,2926,2928],{"className":609,"code":2927,"language":611,"meta":421,"style":421},"{ pkgs, pkgs-unstable, lib, ... }:\n\nlet\n  domain = \"mysite.nix.example.com\";\n  repo = \"git@github.com:you\u002Fmysite.git\";\n  stateDir = \"\u002Fvar\u002Flib\u002Fmysite\";\n  port = 3000;\n\n  bun = pkgs-unstable.bun;                                    # bun.lock v2 needs Bun 1.4\n  libPath = lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];    # libstdc++ \u002F libgcc_s for sharp & oxide\n\n  # Fetch the latest commit, build it into its own release folder, switch to it\n  deployScript = pkgs.writeShellApplication {\n    name = \"mysite-deploy\";\n    runtimeInputs = [ bun pkgs.git pkgs.openssh pkgs.coreutils pkgs.findutils ];\n    text = ''\n      cd ${stateDir}\n      if [ ! -d repo ]; then\n        git clone --branch main ${repo} repo\n      fi\n      cd repo\n      git fetch origin main\n      git reset --hard origin\u002Fmain\n      git clean -fdx -e node_modules\n\n      rev=$(git rev-parse --short HEAD)\n      release=${stateDir}\u002Freleases\u002F$rev\n\n      if [ -d \"$release\" ]; then\n        echo \"Release $rev is already built\"\n      else\n        bun install --frozen-lockfile\n        bun run build\n        mkdir -p \"$release\"\n        cp -r .output \"$release\u002F\"\n      fi\n\n      ln -sfn \"$release\" ${stateDir}\u002Fcurrent.new\n      mv -T ${stateDir}\u002Fcurrent.new ${stateDir}\u002Fcurrent     # atomic switch\n\n      # keep the 5 newest releases\n      find ${stateDir}\u002Freleases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' \\\n        | sort -rn | tail -n +6 | cut -d' ' -f2- | xargs -r rm -rf\n\n      echo \"Deployed $rev\"\n    '';\n  };\n\n  # The only thing the CI key may run: start the deploy, stream its log back\n  ciTrigger = pkgs.writeShellApplication {\n    name = \"mysite-ci-trigger\";\n    runtimeInputs = [ pkgs.coreutils pkgs.systemd ];\n    text = ''\n      since=$(date '+%Y-%m-%d %H:%M:%S')\n      status=0\n      \u002Frun\u002Fwrappers\u002Fbin\u002Fsudo ${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy || status=$?\n      journalctl -u mysite-deploy --since \"$since\" --no-pager --output cat || true\n      exit \"$status\"\n    '';\n  };\n\n  ciKey = \"ssh-ed25519 AAAA... github-actions deploy\";\nin\n{\n  users.users.mysite = {\n    isSystemUser = true;\n    group = \"mysite\";\n    home = stateDir;\n    createHome = true;\n  };\n  users.groups.mysite = { };\n\n  # Verify github.com when cloning (background jobs can't answer the \"yes\u002Fno\" prompt)\n  programs.ssh.knownHosts.\"github.com\".publicKey =\n    \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\";\n\n  # The site\n  systemd.services.mysite = {\n    description = \"My Nuxt site (Bun)\";\n    wantedBy = [ \"multi-user.target\" ];\n    after = [ \"network.target\" ];\n    unitConfig.ConditionPathExists = \"${stateDir}\u002Fcurrent\u002F.output\u002Fserver\u002Findex.mjs\";  # wait for the first build\n    environment = {\n      NODE_ENV = \"production\";\n      HOST = \"127.0.0.1\";\n      PORT = toString port;\n      LD_LIBRARY_PATH = libPath;\n    };\n    serviceConfig = {\n      User = \"mysite\";\n      Group = \"mysite\";\n      WorkingDirectory = \"${stateDir}\u002Fcurrent\";\n      ExecStart = \"${lib.getExe bun} .output\u002Fserver\u002Findex.mjs\";\n      Restart = \"on-failure\";\n      RestartSec = 5;\n      NoNewPrivileges = true;\n      PrivateTmp = true;\n      ProtectSystem = \"strict\";          # whole filesystem read-only…\n      ProtectHome = true;\n      ReadWritePaths = [ stateDir ];     # …except its own folder\n    };\n  };\n\n  # The build\u002Fdeploy job\n  systemd.services.mysite-deploy = {\n    description = \"Build and deploy my site from GitHub\";\n    environment = {\n      HOME = stateDir;\n      GIT_SSH_COMMAND = \"ssh -i ${stateDir}\u002F.ssh\u002Fid_ed25519 -o IdentitiesOnly=yes\";\n      LD_LIBRARY_PATH = libPath;\n    };\n    serviceConfig = {\n      Type = \"oneshot\";\n      User = \"mysite\";\n      Group = \"mysite\";\n      ExecStart = lib.getExe deployScript;\n      ExecStartPost = \"+${pkgs.systemd}\u002Fbin\u002Fsystemctl restart mysite.service\";  # \"+\" = this line runs as root\n      TimeoutStartSec = \"30min\";\n    };\n  };\n\n  # The user GitHub Actions logs in as: one forced command, one sudo rule\n  users.users.mysite-ci = {\n    isSystemUser = true;\n    group = \"mysite-ci\";\n    shell = pkgs.bash;                      # sshd runs forced commands through the login shell\n    extraGroups = [ \"systemd-journal\" ];    # to stream the deploy log back\n    openssh.authorizedKeys.keys = [\n      ''command=\"${lib.getExe ciTrigger}\",restrict ${ciKey}''\n    ];\n  };\n  users.groups.mysite-ci = { };\n\n  security.sudo.extraRules = [{\n    users = [ \"mysite-ci\" ];\n    commands = [{\n      command = \"${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy\";\n      options = [ \"NOPASSWD\" ];\n    }];\n  }];\n\n  services.caddy.virtualHosts.${domain}.extraConfig = ''\n    reverse_proxy 127.0.0.1:${toString port}\n  '';\n}\n",[122,2929,2930,2935,2939,2943,2948,2953,2958,2963,2967,2972,2977,2981,2986,2991,2996,3001,3006,3011,3016,3021,3026,3031,3036,3041,3046,3050,3055,3060,3064,3069,3074,3079,3084,3089,3094,3099,3103,3107,3112,3117,3121,3126,3131,3136,3140,3145,3149,3153,3157,3162,3167,3172,3177,3181,3186,3191,3196,3201,3206,3210,3214,3218,3223,3228,3233,3239,3245,3251,3257,3263,3268,3274,3279,3285,3291,3297,3302,3308,3314,3320,3326,3332,3338,3344,3350,3356,3362,3368,3373,3379,3385,3391,3397,3403,3409,3415,3421,3427,3433,3439,3445,3450,3455,3460,3466,3472,3478,3483,3489,3495,3500,3505,3510,3516,3521,3526,3532,3538,3544,3549,3554,3559,3565,3571,3576,3582,3588,3594,3600,3606,3612,3617,3623,3628,3634,3640,3646,3652,3658,3664,3670,3675,3681,3687,3692],{"__ignoreMap":421},[197,2931,2932],{"class":426,"line":427},[197,2933,2934],{},"{ pkgs, pkgs-unstable, lib, ... }:\n",[197,2936,2937],{"class":426,"line":433},[197,2938,629],{"emptyLinePlaceholder":628},[197,2940,2941],{"class":426,"line":439},[197,2942,929],{},[197,2944,2945],{"class":426,"line":445},[197,2946,2947],{},"  domain = \"mysite.nix.example.com\";\n",[197,2949,2950],{"class":426,"line":512},[197,2951,2952],{},"  repo = \"git@github.com:you\u002Fmysite.git\";\n",[197,2954,2955],{"class":426,"line":557},[197,2956,2957],{},"  stateDir = \"\u002Fvar\u002Flib\u002Fmysite\";\n",[197,2959,2960],{"class":426,"line":563},[197,2961,2962],{},"  port = 3000;\n",[197,2964,2965],{"class":426,"line":569},[197,2966,629],{"emptyLinePlaceholder":628},[197,2968,2969],{"class":426,"line":575},[197,2970,2971],{},"  bun = pkgs-unstable.bun;                                    # bun.lock v2 needs Bun 1.4\n",[197,2973,2974],{"class":426,"line":581},[197,2975,2976],{},"  libPath = lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];    # libstdc++ \u002F libgcc_s for sharp & oxide\n",[197,2978,2979],{"class":426,"line":665},[197,2980,629],{"emptyLinePlaceholder":628},[197,2982,2983],{"class":426,"line":671},[197,2984,2985],{},"  # Fetch the latest commit, build it into its own release folder, switch to it\n",[197,2987,2988],{"class":426,"line":677},[197,2989,2990],{},"  deployScript = pkgs.writeShellApplication {\n",[197,2992,2993],{"class":426,"line":683},[197,2994,2995],{},"    name = \"mysite-deploy\";\n",[197,2997,2998],{"class":426,"line":689},[197,2999,3000],{},"    runtimeInputs = [ bun pkgs.git pkgs.openssh pkgs.coreutils pkgs.findutils ];\n",[197,3002,3003],{"class":426,"line":695},[197,3004,3005],{},"    text = ''\n",[197,3007,3008],{"class":426,"line":700},[197,3009,3010],{},"      cd ${stateDir}\n",[197,3012,3013],{"class":426,"line":706},[197,3014,3015],{},"      if [ ! -d repo ]; then\n",[197,3017,3018],{"class":426,"line":712},[197,3019,3020],{},"        git clone --branch main ${repo} repo\n",[197,3022,3023],{"class":426,"line":718},[197,3024,3025],{},"      fi\n",[197,3027,3028],{"class":426,"line":723},[197,3029,3030],{},"      cd repo\n",[197,3032,3033],{"class":426,"line":728},[197,3034,3035],{},"      git fetch origin main\n",[197,3037,3038],{"class":426,"line":879},[197,3039,3040],{},"      git reset --hard origin\u002Fmain\n",[197,3042,3043],{"class":426,"line":884},[197,3044,3045],{},"      git clean -fdx -e node_modules\n",[197,3047,3048],{"class":426,"line":889},[197,3049,629],{"emptyLinePlaceholder":628},[197,3051,3052],{"class":426,"line":1039},[197,3053,3054],{},"      rev=$(git rev-parse --short HEAD)\n",[197,3056,3057],{"class":426,"line":1044},[197,3058,3059],{},"      release=${stateDir}\u002Freleases\u002F$rev\n",[197,3061,3062],{"class":426,"line":1050},[197,3063,629],{"emptyLinePlaceholder":628},[197,3065,3066],{"class":426,"line":1055},[197,3067,3068],{},"      if [ -d \"$release\" ]; then\n",[197,3070,3071],{"class":426,"line":1061},[197,3072,3073],{},"        echo \"Release $rev is already built\"\n",[197,3075,3076],{"class":426,"line":1067},[197,3077,3078],{},"      else\n",[197,3080,3081],{"class":426,"line":1072},[197,3082,3083],{},"        bun install --frozen-lockfile\n",[197,3085,3086],{"class":426,"line":1078},[197,3087,3088],{},"        bun run build\n",[197,3090,3091],{"class":426,"line":1084},[197,3092,3093],{},"        mkdir -p \"$release\"\n",[197,3095,3096],{"class":426,"line":1090},[197,3097,3098],{},"        cp -r .output \"$release\u002F\"\n",[197,3100,3101],{"class":426,"line":1096},[197,3102,3025],{},[197,3104,3105],{"class":426,"line":1101},[197,3106,629],{"emptyLinePlaceholder":628},[197,3108,3109],{"class":426,"line":1106},[197,3110,3111],{},"      ln -sfn \"$release\" ${stateDir}\u002Fcurrent.new\n",[197,3113,3114],{"class":426,"line":1111},[197,3115,3116],{},"      mv -T ${stateDir}\u002Fcurrent.new ${stateDir}\u002Fcurrent     # atomic switch\n",[197,3118,3119],{"class":426,"line":1117},[197,3120,629],{"emptyLinePlaceholder":628},[197,3122,3123],{"class":426,"line":1123},[197,3124,3125],{},"      # keep the 5 newest releases\n",[197,3127,3128],{"class":426,"line":1129},[197,3129,3130],{},"      find ${stateDir}\u002Freleases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' \\\n",[197,3132,3133],{"class":426,"line":1135},[197,3134,3135],{},"        | sort -rn | tail -n +6 | cut -d' ' -f2- | xargs -r rm -rf\n",[197,3137,3138],{"class":426,"line":1141},[197,3139,629],{"emptyLinePlaceholder":628},[197,3141,3142],{"class":426,"line":1147},[197,3143,3144],{},"      echo \"Deployed $rev\"\n",[197,3146,3147],{"class":426,"line":1152},[197,3148,2376],{},[197,3150,3151],{"class":426,"line":1158},[197,3152,658],{},[197,3154,3155],{"class":426,"line":1164},[197,3156,629],{"emptyLinePlaceholder":628},[197,3158,3159],{"class":426,"line":1169},[197,3160,3161],{},"  # The only thing the CI key may run: start the deploy, stream its log back\n",[197,3163,3164],{"class":426,"line":1175},[197,3165,3166],{},"  ciTrigger = pkgs.writeShellApplication {\n",[197,3168,3169],{"class":426,"line":1181},[197,3170,3171],{},"    name = \"mysite-ci-trigger\";\n",[197,3173,3174],{"class":426,"line":1187},[197,3175,3176],{},"    runtimeInputs = [ pkgs.coreutils pkgs.systemd ];\n",[197,3178,3179],{"class":426,"line":1193},[197,3180,3005],{},[197,3182,3183],{"class":426,"line":1198},[197,3184,3185],{},"      since=$(date '+%Y-%m-%d %H:%M:%S')\n",[197,3187,3188],{"class":426,"line":1204},[197,3189,3190],{},"      status=0\n",[197,3192,3193],{"class":426,"line":1210},[197,3194,3195],{},"      \u002Frun\u002Fwrappers\u002Fbin\u002Fsudo ${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy || status=$?\n",[197,3197,3198],{"class":426,"line":1215},[197,3199,3200],{},"      journalctl -u mysite-deploy --since \"$since\" --no-pager --output cat || true\n",[197,3202,3203],{"class":426,"line":1221},[197,3204,3205],{},"      exit \"$status\"\n",[197,3207,3208],{"class":426,"line":1226},[197,3209,2376],{},[197,3211,3212],{"class":426,"line":1232},[197,3213,658],{},[197,3215,3216],{"class":426,"line":1237},[197,3217,629],{"emptyLinePlaceholder":628},[197,3219,3220],{"class":426,"line":1243},[197,3221,3222],{},"  ciKey = \"ssh-ed25519 AAAA... github-actions deploy\";\n",[197,3224,3226],{"class":426,"line":3225},63,[197,3227,939],{},[197,3229,3231],{"class":426,"line":3230},64,[197,3232,618],{},[197,3234,3236],{"class":426,"line":3235},65,[197,3237,3238],{},"  users.users.mysite = {\n",[197,3240,3242],{"class":426,"line":3241},66,[197,3243,3244],{},"    isSystemUser = true;\n",[197,3246,3248],{"class":426,"line":3247},67,[197,3249,3250],{},"    group = \"mysite\";\n",[197,3252,3254],{"class":426,"line":3253},68,[197,3255,3256],{},"    home = stateDir;\n",[197,3258,3260],{"class":426,"line":3259},69,[197,3261,3262],{},"    createHome = true;\n",[197,3264,3266],{"class":426,"line":3265},70,[197,3267,658],{},[197,3269,3271],{"class":426,"line":3270},71,[197,3272,3273],{},"  users.groups.mysite = { };\n",[197,3275,3277],{"class":426,"line":3276},72,[197,3278,629],{"emptyLinePlaceholder":628},[197,3280,3282],{"class":426,"line":3281},73,[197,3283,3284],{},"  # Verify github.com when cloning (background jobs can't answer the \"yes\u002Fno\" prompt)\n",[197,3286,3288],{"class":426,"line":3287},74,[197,3289,3290],{},"  programs.ssh.knownHosts.\"github.com\".publicKey =\n",[197,3292,3294],{"class":426,"line":3293},75,[197,3295,3296],{},"    \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\";\n",[197,3298,3300],{"class":426,"line":3299},76,[197,3301,629],{"emptyLinePlaceholder":628},[197,3303,3305],{"class":426,"line":3304},77,[197,3306,3307],{},"  # The site\n",[197,3309,3311],{"class":426,"line":3310},78,[197,3312,3313],{},"  systemd.services.mysite = {\n",[197,3315,3317],{"class":426,"line":3316},79,[197,3318,3319],{},"    description = \"My Nuxt site (Bun)\";\n",[197,3321,3323],{"class":426,"line":3322},80,[197,3324,3325],{},"    wantedBy = [ \"multi-user.target\" ];\n",[197,3327,3329],{"class":426,"line":3328},81,[197,3330,3331],{},"    after = [ \"network.target\" ];\n",[197,3333,3335],{"class":426,"line":3334},82,[197,3336,3337],{},"    unitConfig.ConditionPathExists = \"${stateDir}\u002Fcurrent\u002F.output\u002Fserver\u002Findex.mjs\";  # wait for the first build\n",[197,3339,3341],{"class":426,"line":3340},83,[197,3342,3343],{},"    environment = {\n",[197,3345,3347],{"class":426,"line":3346},84,[197,3348,3349],{},"      NODE_ENV = \"production\";\n",[197,3351,3353],{"class":426,"line":3352},85,[197,3354,3355],{},"      HOST = \"127.0.0.1\";\n",[197,3357,3359],{"class":426,"line":3358},86,[197,3360,3361],{},"      PORT = toString port;\n",[197,3363,3365],{"class":426,"line":3364},87,[197,3366,3367],{},"      LD_LIBRARY_PATH = libPath;\n",[197,3369,3371],{"class":426,"line":3370},88,[197,3372,692],{},[197,3374,3376],{"class":426,"line":3375},89,[197,3377,3378],{},"    serviceConfig = {\n",[197,3380,3382],{"class":426,"line":3381},90,[197,3383,3384],{},"      User = \"mysite\";\n",[197,3386,3388],{"class":426,"line":3387},91,[197,3389,3390],{},"      Group = \"mysite\";\n",[197,3392,3394],{"class":426,"line":3393},92,[197,3395,3396],{},"      WorkingDirectory = \"${stateDir}\u002Fcurrent\";\n",[197,3398,3400],{"class":426,"line":3399},93,[197,3401,3402],{},"      ExecStart = \"${lib.getExe bun} .output\u002Fserver\u002Findex.mjs\";\n",[197,3404,3406],{"class":426,"line":3405},94,[197,3407,3408],{},"      Restart = \"on-failure\";\n",[197,3410,3412],{"class":426,"line":3411},95,[197,3413,3414],{},"      RestartSec = 5;\n",[197,3416,3418],{"class":426,"line":3417},96,[197,3419,3420],{},"      NoNewPrivileges = true;\n",[197,3422,3424],{"class":426,"line":3423},97,[197,3425,3426],{},"      PrivateTmp = true;\n",[197,3428,3430],{"class":426,"line":3429},98,[197,3431,3432],{},"      ProtectSystem = \"strict\";          # whole filesystem read-only…\n",[197,3434,3436],{"class":426,"line":3435},99,[197,3437,3438],{},"      ProtectHome = true;\n",[197,3440,3442],{"class":426,"line":3441},100,[197,3443,3444],{},"      ReadWritePaths = [ stateDir ];     # …except its own folder\n",[197,3446,3448],{"class":426,"line":3447},101,[197,3449,692],{},[197,3451,3453],{"class":426,"line":3452},102,[197,3454,658],{},[197,3456,3458],{"class":426,"line":3457},103,[197,3459,629],{"emptyLinePlaceholder":628},[197,3461,3463],{"class":426,"line":3462},104,[197,3464,3465],{},"  # The build\u002Fdeploy job\n",[197,3467,3469],{"class":426,"line":3468},105,[197,3470,3471],{},"  systemd.services.mysite-deploy = {\n",[197,3473,3475],{"class":426,"line":3474},106,[197,3476,3477],{},"    description = \"Build and deploy my site from GitHub\";\n",[197,3479,3481],{"class":426,"line":3480},107,[197,3482,3343],{},[197,3484,3486],{"class":426,"line":3485},108,[197,3487,3488],{},"      HOME = stateDir;\n",[197,3490,3492],{"class":426,"line":3491},109,[197,3493,3494],{},"      GIT_SSH_COMMAND = \"ssh -i ${stateDir}\u002F.ssh\u002Fid_ed25519 -o IdentitiesOnly=yes\";\n",[197,3496,3498],{"class":426,"line":3497},110,[197,3499,3367],{},[197,3501,3503],{"class":426,"line":3502},111,[197,3504,692],{},[197,3506,3508],{"class":426,"line":3507},112,[197,3509,3378],{},[197,3511,3513],{"class":426,"line":3512},113,[197,3514,3515],{},"      Type = \"oneshot\";\n",[197,3517,3519],{"class":426,"line":3518},114,[197,3520,3384],{},[197,3522,3524],{"class":426,"line":3523},115,[197,3525,3390],{},[197,3527,3529],{"class":426,"line":3528},116,[197,3530,3531],{},"      ExecStart = lib.getExe deployScript;\n",[197,3533,3535],{"class":426,"line":3534},117,[197,3536,3537],{},"      ExecStartPost = \"+${pkgs.systemd}\u002Fbin\u002Fsystemctl restart mysite.service\";  # \"+\" = this line runs as root\n",[197,3539,3541],{"class":426,"line":3540},118,[197,3542,3543],{},"      TimeoutStartSec = \"30min\";\n",[197,3545,3547],{"class":426,"line":3546},119,[197,3548,692],{},[197,3550,3552],{"class":426,"line":3551},120,[197,3553,658],{},[197,3555,3557],{"class":426,"line":3556},121,[197,3558,629],{"emptyLinePlaceholder":628},[197,3560,3562],{"class":426,"line":3561},122,[197,3563,3564],{},"  # The user GitHub Actions logs in as: one forced command, one sudo rule\n",[197,3566,3568],{"class":426,"line":3567},123,[197,3569,3570],{},"  users.users.mysite-ci = {\n",[197,3572,3574],{"class":426,"line":3573},124,[197,3575,3244],{},[197,3577,3579],{"class":426,"line":3578},125,[197,3580,3581],{},"    group = \"mysite-ci\";\n",[197,3583,3585],{"class":426,"line":3584},126,[197,3586,3587],{},"    shell = pkgs.bash;                      # sshd runs forced commands through the login shell\n",[197,3589,3591],{"class":426,"line":3590},127,[197,3592,3593],{},"    extraGroups = [ \"systemd-journal\" ];    # to stream the deploy log back\n",[197,3595,3597],{"class":426,"line":3596},128,[197,3598,3599],{},"    openssh.authorizedKeys.keys = [\n",[197,3601,3603],{"class":426,"line":3602},129,[197,3604,3605],{},"      ''command=\"${lib.getExe ciTrigger}\",restrict ${ciKey}''\n",[197,3607,3609],{"class":426,"line":3608},130,[197,3610,3611],{},"    ];\n",[197,3613,3615],{"class":426,"line":3614},131,[197,3616,658],{},[197,3618,3620],{"class":426,"line":3619},132,[197,3621,3622],{},"  users.groups.mysite-ci = { };\n",[197,3624,3626],{"class":426,"line":3625},133,[197,3627,629],{"emptyLinePlaceholder":628},[197,3629,3631],{"class":426,"line":3630},134,[197,3632,3633],{},"  security.sudo.extraRules = [{\n",[197,3635,3637],{"class":426,"line":3636},135,[197,3638,3639],{},"    users = [ \"mysite-ci\" ];\n",[197,3641,3643],{"class":426,"line":3642},136,[197,3644,3645],{},"    commands = [{\n",[197,3647,3649],{"class":426,"line":3648},137,[197,3650,3651],{},"      command = \"${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy\";\n",[197,3653,3655],{"class":426,"line":3654},138,[197,3656,3657],{},"      options = [ \"NOPASSWD\" ];\n",[197,3659,3661],{"class":426,"line":3660},139,[197,3662,3663],{},"    }];\n",[197,3665,3667],{"class":426,"line":3666},140,[197,3668,3669],{},"  }];\n",[197,3671,3673],{"class":426,"line":3672},141,[197,3674,629],{"emptyLinePlaceholder":628},[197,3676,3678],{"class":426,"line":3677},142,[197,3679,3680],{},"  services.caddy.virtualHosts.${domain}.extraConfig = ''\n",[197,3682,3684],{"class":426,"line":3683},143,[197,3685,3686],{},"    reverse_proxy 127.0.0.1:${toString port}\n",[197,3688,3690],{"class":426,"line":3689},144,[197,3691,2339],{},[197,3693,3695],{"class":426,"line":3694},145,[197,3696,731],{},[10,3698,3699,3700,3703,3704,3707,3708,3710,3711,3714,3715,3718,3719,2167,3722,2183,3725,3728],{},"That ",[122,3701,3702],{},"services.caddy.virtualHosts"," block lands in the ",[28,3705,3706],{},"same"," Caddy as the public repo's ",[122,3709,2417],{},"; NixOS merges modules. ",[122,3712,3713],{},"writeShellApplication"," runs ",[28,3716,3717],{},"shellcheck"," at build time, so bash mistakes fail the deploy, not the site. (I had ",[122,3720,3721],{},"git rev-phase",[122,3723,3724],{},"--fdx",[122,3726,3727],{},"\"$release\"]"," in my first draft.)",[10,3730,3731],{},"How it maps to Docker:",[97,3733,3734,3744],{},[100,3735,3736],{},[103,3737,3738,3741],{},[106,3739,3740],{},"Dokploy \u002F Docker",[106,3742,3743],{},"NixOS",[111,3745,3746,3756,3767,3779],{},[103,3747,3748,3751],{},[116,3749,3750],{},"Dockerfile build stage",[116,3752,3753],{},[122,3754,3755],{},"deployScript",[103,3757,3758,3761],{},[116,3759,3760],{},"Container",[116,3762,3763,3766],{},[122,3764,3765],{},"mysite.service"," + systemd sandboxing",[103,3768,3769,3772],{},[116,3770,3771],{},"Traefik + domain",[116,3773,3774,3775,3778],{},"the Caddy ",[122,3776,3777],{},"virtualHosts"," block",[103,3780,3781,3784],{},[116,3782,3783],{},"Previous deployments",[116,3785,3786,3789,3790,3793],{},[122,3787,3788],{},"releases\u002F\u003Csha>"," + the ",[122,3791,3792],{},"current"," symlink",[191,3795,3796],{},[10,3797,3798,3805,3806,3808,3809,3812],{},[28,3799,3800,3801,3804],{},"Never put secrets in a ",[122,3802,3803],{},".nix"," file."," Everything Nix builds lands in ",[122,3807,479],{},", readable by every user on the machine, and that includes unit files with their ",[122,3810,3811],{},"environment",". Use a file only root can read, or sops-nix.",[173,3814,3816],{"id":3815},"a-read-only-deploy-key","A read-only deploy key",[10,3818,3819,3820,3823],{},"The repo is private. A ",[28,3821,3822],{},"deploy key"," belongs to one repo, not your account, and can be read-only:",[416,3825,3827],{"className":1917,"code":3826,"language":1919,"meta":421,"style":421},"sudo -u mysite mkdir -p -m 700 \u002Fvar\u002Flib\u002Fmysite\u002F.ssh        # owned by the service user; SSH rejects loose permissions\nsudo -u mysite ssh-keygen -t ed25519 -N \"\" -C \"server deploy key\" -f \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519\nsudo cat \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519.pub                # → repo → Settings → Deploy keys (write access OFF)\nsudo -u mysite ssh -i \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519 -o IdentitiesOnly=yes -T git@github.com\n# Hi you\u002Fmysite! You've successfully authenticated...\n",[122,3828,3829,3857,3892,3905,3934],{"__ignoreMap":421},[197,3830,3831,3833,3836,3839,3842,3845,3848,3851,3854],{"class":426,"line":427},[197,3832,1956],{"class":1932},[197,3834,3835],{"class":1962}," -u",[197,3837,3838],{"class":1935}," mysite",[197,3840,3841],{"class":1935}," mkdir",[197,3843,3844],{"class":1962}," -p",[197,3846,3847],{"class":1962}," -m",[197,3849,3850],{"class":1962}," 700",[197,3852,3853],{"class":1935}," \u002Fvar\u002Flib\u002Fmysite\u002F.ssh",[197,3855,3856],{"class":1926},"        # owned by the service user; SSH rejects loose permissions\n",[197,3858,3859,3861,3863,3865,3868,3871,3874,3877,3880,3883,3886,3889],{"class":426,"line":433},[197,3860,1956],{"class":1932},[197,3862,3835],{"class":1962},[197,3864,3838],{"class":1935},[197,3866,3867],{"class":1935}," ssh-keygen",[197,3869,3870],{"class":1962}," -t",[197,3872,3873],{"class":1935}," ed25519",[197,3875,3876],{"class":1962}," -N",[197,3878,3879],{"class":1935}," \"\"",[197,3881,3882],{"class":1962}," -C",[197,3884,3885],{"class":1935}," \"server deploy key\"",[197,3887,3888],{"class":1962}," -f",[197,3890,3891],{"class":1935}," \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519\n",[197,3893,3894,3896,3899,3902],{"class":426,"line":439},[197,3895,1956],{"class":1932},[197,3897,3898],{"class":1935}," cat",[197,3900,3901],{"class":1935}," \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519.pub",[197,3903,3904],{"class":1926},"                # → repo → Settings → Deploy keys (write access OFF)\n",[197,3906,3907,3909,3911,3913,3916,3919,3922,3925,3928,3931],{"class":426,"line":445},[197,3908,1956],{"class":1932},[197,3910,3835],{"class":1962},[197,3912,3838],{"class":1935},[197,3914,3915],{"class":1935}," ssh",[197,3917,3918],{"class":1962}," -i",[197,3920,3921],{"class":1935}," \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519",[197,3923,3924],{"class":1962}," -o",[197,3926,3927],{"class":1935}," IdentitiesOnly=yes",[197,3929,3930],{"class":1962}," -T",[197,3932,3933],{"class":1935}," git@github.com\n",[197,3935,3936],{"class":426,"line":512},[197,3937,3938],{"class":1926},"# Hi you\u002Fmysite! You've successfully authenticated...\n",[10,3940,3941,3942,3945,3946,3949],{},"First build: ",[122,3943,3944],{},"journalctl -u mysite-deploy -f"," in one terminal, ",[122,3947,3948],{},"sudo systemctl start mysite-deploy"," in another. A few minutes later:",[416,3951,3953],{"className":525,"code":3952,"language":527,"meta":421,"style":421},"● mysite.service - My Nuxt site (Bun)\n     Active: active (running)\n     Memory: 28.6M\nbun[9952]: Listening on http:\u002F\u002F127.0.0.1:3000\n",[122,3954,3955,3960,3965,3970],{"__ignoreMap":421},[197,3956,3957],{"class":426,"line":427},[197,3958,3959],{},"● mysite.service - My Nuxt site (Bun)\n",[197,3961,3962],{"class":426,"line":433},[197,3963,3964],{},"     Active: active (running)\n",[197,3966,3967],{"class":426,"line":439},[197,3968,3969],{},"     Memory: 28.6M\n",[197,3971,3972],{"class":426,"line":445},[197,3973,3974],{},"bun[9952]: Listening on http:\u002F\u002F127.0.0.1:3000\n",[10,3976,3977,3980,3981,3983],{},[28,3978,3979],{},"About 29 MB of RAM"," for the running site. I checked an on-demand AVIF resize from outside, which proves ",[122,3982,2866],{}," works on NixOS.",[173,3985,3987],{"id":3986},"deploy-on-push-github-actions-with-a-locked-down-key","Deploy on push: GitHub Actions with a locked-down key",[10,3989,3990],{},"GitHub gets an SSH key to my server, so that key can do exactly one thing.",[10,3992,3993],{},[67,3994],{"alt":3995,"height":3996,"src":3997,"width":90},"Six steps from a git push or CMS save to a live release: the GitHub Action, the forced command, the single sudo rule, the build into its own release folder, and the symlink swap",2386,"\u002Fuploads\u002Fblog\u002Fnixos-deploy.webp",[216,3999,4000,4016,4026],{},[36,4001,4002,2912,4005,2254,4008,4011,4012,4015],{},[122,4003,4004],{},"command=\"…\"",[122,4006,4007],{},"authorized_keys",[28,4009,4010],{},"every"," connection runs the trigger, whatever was asked. ",[122,4013,4014],{},"ssh … 'cat \u002Fetc\u002Fpasswd'"," just deploys.",[36,4017,4018,4021,4022,4025],{},[122,4019,4020],{},"restrict",": no terminal and no forwarding. If you test it interactively, you'll see ",[122,4023,4024],{},"PTY allocation request failed",", which means it's working.",[36,4027,4028,4031,4032,4035],{},[122,4029,4030],{},"systemctl start"," on a oneshot ",[28,4033,4034],{},"waits"," for the job and returns its exit code, so GitHub shows a pass or a fail.",[10,4037,4038,4039,4042,4043,2272],{},"In the app repo, add two secrets: the CI private key, and the server's host key line (",[122,4040,4041],{},"ssh-keyscan -t ed25519 \u003Cinstance-ip>",", after checking its fingerprint matches the one you saw on first login). Then ",[122,4044,4045],{},".github\u002Fworkflows\u002Fdeploy-nixos.yml",[416,4047,4051],{"className":4048,"code":4049,"language":4050,"meta":421,"style":421},"language-yaml shiki shiki-themes github-light github-dark","name: Deploy to NixOS\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:          # \"Run workflow\" button = manual redeploy\n\n# One deploy at a time: a push during a running deploy waits, then deploys the newest commit\nconcurrency:\n  group: deploy-nixos\n  cancel-in-progress: false\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    timeout-minutes: 40\n    steps:\n      - name: Build and deploy on the server\n        env:\n          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}\n          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}\n        run: |\n          install -d -m 700 ~\u002F.ssh\n          printf '%s\\n' \"$SSH_KEY\" > ~\u002F.ssh\u002Fdeploy_key\n          chmod 600 ~\u002F.ssh\u002Fdeploy_key\n          printf '%s\\n' \"$KNOWN_HOSTS\" > ~\u002F.ssh\u002Fknown_hosts\n          ssh -T -i ~\u002F.ssh\u002Fdeploy_key -o IdentitiesOnly=yes -o ServerAliveInterval=30 mysite-ci@\u003Cinstance-ip>\n","yaml",[122,4052,4053,4065,4069,4077,4084,4098,4109,4113,4118,4125,4135,4145,4149,4156,4163,4173,4183,4190,4202,4209,4219,4229,4240,4245,4250,4255,4260],{"__ignoreMap":421},[197,4054,4055,4059,4062],{"class":426,"line":427},[197,4056,4058],{"class":4057},"s9eBZ","name",[197,4060,2254],{"class":4061},"sVt8B",[197,4063,4064],{"class":1935},"Deploy to NixOS\n",[197,4066,4067],{"class":426,"line":433},[197,4068,629],{"emptyLinePlaceholder":628},[197,4070,4071,4074],{"class":426,"line":439},[197,4072,4073],{"class":1962},"on",[197,4075,4076],{"class":4061},":\n",[197,4078,4079,4082],{"class":426,"line":445},[197,4080,4081],{"class":4057},"  push",[197,4083,4076],{"class":4061},[197,4085,4086,4089,4092,4095],{"class":426,"line":512},[197,4087,4088],{"class":4057},"    branches",[197,4090,4091],{"class":4061},": [",[197,4093,4094],{"class":1935},"main",[197,4096,4097],{"class":4061},"]\n",[197,4099,4100,4103,4106],{"class":426,"line":557},[197,4101,4102],{"class":4057},"  workflow_dispatch",[197,4104,4105],{"class":4061},":          ",[197,4107,4108],{"class":1926},"# \"Run workflow\" button = manual redeploy\n",[197,4110,4111],{"class":426,"line":563},[197,4112,629],{"emptyLinePlaceholder":628},[197,4114,4115],{"class":426,"line":569},[197,4116,4117],{"class":1926},"# One deploy at a time: a push during a running deploy waits, then deploys the newest commit\n",[197,4119,4120,4123],{"class":426,"line":575},[197,4121,4122],{"class":4057},"concurrency",[197,4124,4076],{"class":4061},[197,4126,4127,4130,4132],{"class":426,"line":581},[197,4128,4129],{"class":4057},"  group",[197,4131,2254],{"class":4061},[197,4133,4134],{"class":1935},"deploy-nixos\n",[197,4136,4137,4140,4142],{"class":426,"line":665},[197,4138,4139],{"class":4057},"  cancel-in-progress",[197,4141,2254],{"class":4061},[197,4143,4144],{"class":1962},"false\n",[197,4146,4147],{"class":426,"line":671},[197,4148,629],{"emptyLinePlaceholder":628},[197,4150,4151,4154],{"class":426,"line":677},[197,4152,4153],{"class":4057},"jobs",[197,4155,4076],{"class":4061},[197,4157,4158,4161],{"class":426,"line":683},[197,4159,4160],{"class":4057},"  deploy",[197,4162,4076],{"class":4061},[197,4164,4165,4168,4170],{"class":426,"line":689},[197,4166,4167],{"class":4057},"    runs-on",[197,4169,2254],{"class":4061},[197,4171,4172],{"class":1935},"ubuntu-latest\n",[197,4174,4175,4178,4180],{"class":426,"line":695},[197,4176,4177],{"class":4057},"    timeout-minutes",[197,4179,2254],{"class":4061},[197,4181,4182],{"class":1962},"40\n",[197,4184,4185,4188],{"class":426,"line":700},[197,4186,4187],{"class":4057},"    steps",[197,4189,4076],{"class":4061},[197,4191,4192,4195,4197,4199],{"class":426,"line":706},[197,4193,4194],{"class":4061},"      - ",[197,4196,4058],{"class":4057},[197,4198,2254],{"class":4061},[197,4200,4201],{"class":1935},"Build and deploy on the server\n",[197,4203,4204,4207],{"class":426,"line":712},[197,4205,4206],{"class":4057},"        env",[197,4208,4076],{"class":4061},[197,4210,4211,4214,4216],{"class":426,"line":718},[197,4212,4213],{"class":4057},"          SSH_KEY",[197,4215,2254],{"class":4061},[197,4217,4218],{"class":1935},"${{ secrets.DEPLOY_SSH_KEY }}\n",[197,4220,4221,4224,4226],{"class":426,"line":723},[197,4222,4223],{"class":4057},"          KNOWN_HOSTS",[197,4225,2254],{"class":4061},[197,4227,4228],{"class":1935},"${{ secrets.DEPLOY_KNOWN_HOSTS }}\n",[197,4230,4231,4234,4236],{"class":426,"line":728},[197,4232,4233],{"class":4057},"        run",[197,4235,2254],{"class":4061},[197,4237,4239],{"class":4238},"szBVR","|\n",[197,4241,4242],{"class":426,"line":879},[197,4243,4244],{"class":1935},"          install -d -m 700 ~\u002F.ssh\n",[197,4246,4247],{"class":426,"line":884},[197,4248,4249],{"class":1935},"          printf '%s\\n' \"$SSH_KEY\" > ~\u002F.ssh\u002Fdeploy_key\n",[197,4251,4252],{"class":426,"line":889},[197,4253,4254],{"class":1935},"          chmod 600 ~\u002F.ssh\u002Fdeploy_key\n",[197,4256,4257],{"class":426,"line":1039},[197,4258,4259],{"class":1935},"          printf '%s\\n' \"$KNOWN_HOSTS\" > ~\u002F.ssh\u002Fknown_hosts\n",[197,4261,4262],{"class":426,"line":1044},[197,4263,4264],{"class":1935},"          ssh -T -i ~\u002F.ssh\u002Fdeploy_key -o IdentitiesOnly=yes -o ServerAliveInterval=30 mysite-ci@\u003Cinstance-ip>\n",[10,4266,4267,4269,4270,4272],{},[122,4268,4122],{}," isn't optional here. ",[122,4271,4030],{}," on a oneshot that's already running just joins it, so without it a push during a build would never get deployed.",[10,4274,4275],{},[67,4276],{"alt":4277,"height":4278,"src":4279,"width":4280},"The GitHub Actions log of the first automatic deploy: the Nuxt build completes in 52.8 seconds and the job succeeds in 1 minute 1 second",1005,"\u002Fuploads\u002Fblog\u002Fnixos-shot-github-deploy.webp",1315,[10,4282,4283,4286],{},[28,4284,4285],{},"The first automatic deploy took 1 minute 1 second."," The Nuxt build itself took 52.8 s on 2 OCPUs and peaked at 3.1 GB. On one of my old 4 GB Dokploy server, that same build got killed for running out of memory under Bun, and I had to build under Node with a 768 MB heap cap.",[10,4288,4289,4290,2806,4292,4295],{},"Sveltia CMS saves are just commits to ",[122,4291,4094],{},[28,4293,4294],{},"content edits deploy themselves",". I tested it, and that was the moment Dokploy stopped being necessary for this site.",[92,4297,4299],{"id":4298},"part-8-did-i-deploy-it-the-nix-way","Part 8: Did I deploy it the Nix way?",[10,4301,4302],{},[28,4303,4304],{},"Partly. Honestly, it's a hybrid: managed by NixOS, not built by Nix.",[10,4306,4307],{},[28,4308,4309],{},"What is properly declarative and reproducible:",[216,4311,4312,4315,4318,4327],{},[36,4313,4314],{},"The service, its user, the sandboxing, the reverse proxy and TLS",[36,4316,4317],{},"The deploy job, the CI user, the forced-command key and the single sudo rule",[36,4319,1316,4320,4323,4324],{},[28,4321,4322],{},"exact Bun version"," pinned through ",[122,4325,4326],{},"flake.lock",[36,4328,4329],{},"All of it is versioned in git and rolls back with system generations",[10,4331,4332],{},[28,4333,4334],{},"What isn't:",[216,4336,4337,4359,4369],{},[36,4338,1316,4339,4342,4343,2183,4346,4349,4350,4352,4353,4356,4357,204],{},[28,4340,4341],{},"app itself is built imperatively"," by ",[122,4344,4345],{},"bun install",[122,4347,4348],{},"bun run build"," at deploy time. Dependencies come from npm, pinned by ",[122,4351,2840],{}," but not by Nix hashes. The build needs network access, and its output lives in ",[122,4354,4355],{},"\u002Fvar\u002Flib",", not in ",[122,4358,479],{},[36,4360,4361,4364,4365,4368],{},[28,4362,4363],{},"Site versions aren't part of NixOS generations."," Rolling back the system doesn't roll back the site (the ",[122,4366,4367],{},"releases\u002F"," folder handles that separately).",[36,4370,1316,4371,4377,4378,4380,4381,4384],{},[28,4372,4373,4376],{},[122,4374,4375],{},"LD_LIBRARY_PATH"," workaround"," keeps prebuilt binaries happy. The pure approach builds ",[122,4379,2866],{}," against Nix's ",[122,4382,4383],{},"vips"," instead.",[10,4386,4387,4390,4391,4396,4397,4399,4400,4402,4403,4406,4407,204],{},[28,4388,4389],{},"What the pure way would look like:"," package the site as a Nix derivation, with ",[17,4392,4395],{"href":4393,"rel":4394,"target":22},"https:\u002F\u002Fgithub.com\u002FbaileyluTCD\u002Fbun2nix",[21],"bun2nix"," turning ",[122,4398,2840],{}," into fixed-output dependency fetches. The build output lands in ",[122,4401,479],{},", and the service's ",[122,4404,4405],{},"ExecStart"," points into the store. Deploying a new version means ",[122,4408,4409],{},"nix flake update mysite && just deploy",[10,4411,4412,4415,4416,4419,4420],{},[28,4413,4414],{},"Why I didn't do that (yet):"," with a git-based CMS, ",[28,4417,4418],{},"every content edit would mean updating a lock file and rebuilding the whole system",", and automating that across two repos means giving CI write access to my infra repo. Nuxt, Bun and sharp are also known to be fiddly to package purely. Because content changes much more often than infrastructure, splitting things this way made sense: ",[28,4421,4422],{},"NixOS owns the infrastructure, and the app ships its own releases.",[10,4424,4425],{},"The pure version is on my list as a learning project.",[92,4427,4429],{"id":4428},"part-9-my-mistakes-collected","Part 9: My mistakes, collected",[97,4431,4432,4442],{},[100,4433,4434],{},[103,4435,4436,4439],{},[106,4437,4438],{},"Mistake",[106,4440,4441],{},"How it showed up",[111,4443,4444,4457,4473,4484,4500,4510,4525,4538,4549,4559],{},[103,4445,4446,4451],{},[116,4447,4448,4450],{},[122,4449,742],{}," (no s)",[116,4452,4453,4454],{},"Nothing, until a tool looked for ",[122,4455,4456],{},"nixosConfigurations",[103,4458,4459,4464],{},[116,4460,4461],{},[122,4462,4463],{},"user.users",[116,4465,4466,4469,4470,4472],{},[122,4467,4468],{},"The option 'user' does not exist"," (caught by ",[122,4471,1366],{},")",[103,4474,4475,4479],{},[116,4476,4477],{},[122,4478,2421],{},[116,4480,4481,4482],{},"Syntax error: modules need ",[122,4483,2272],{},[103,4485,4486,4491],{},[116,4487,4488],{},[122,4489,4490],{},"User = \"mysite\",",[116,4492,4493,4496,4497],{},[122,4494,4495],{},"unexpected ','",": attributes end with ",[122,4498,4499],{},";",[103,4501,4502,4507],{},[116,4503,4504],{},[122,4505,4506],{},"github:NixIS\u002Fnixpkgs",[116,4508,4509],{},"Repository not found",[103,4511,4512,4520],{},[116,4513,4514,1880,4517],{},[122,4515,4516],{},"seld",[122,4518,4519],{},"self",[116,4521,4522],{},[122,4523,4524],{},"called without required argument",[103,4526,4527,4533],{},[116,4528,4529,4532],{},[122,4530,4531],{},"ciKey = ssh-ed25519 …"," (no quotes)",[116,4534,4535],{},[122,4536,4537],{},"undefined variable 'ssh-ed25519'",[103,4539,4540,4546],{},[116,4541,4542,4543,4545],{},"Forgot ",[122,4544,601],{}," on a new file (happened numerous times)",[116,4547,4548],{},"Nix acts like the file doesn't exist",[103,4550,4551,4556],{},[116,4552,4553,4554],{},"Password typed at ",[122,4555,252],{},[116,4557,4558],{},"Printed in plain text on the console",[103,4560,4561,4570],{},[116,4562,4563,4566,4567,4472],{},[122,4564,4565],{},"nix-instantiate --parse x.nix \u002Fdev\u002Fnull"," (missing ",[122,4568,4569],{},">",[116,4571,4572,4575],{},[122,4573,4574],{},"'\u002Fdev\u002Fnull' is not a regular file",", though the parse itself was fine",[10,4577,4578],{},"As you can see, majority are typos. :)",[10,4580,4581,4582,4584],{},"The NixOS module system has a schema, so ",[122,4583,1366],{}," catches most of these before anything touches the server. Run it all the time.",[92,4586,4588],{"id":4587},"part-10-whats-next","Part 10: What's next",[10,4590,4591],{},"Some stuff that I plan to use and implement for learning purposes:",[216,4593,4594,4600,4606,4612,4618],{},[36,4595,4596,4599],{},[28,4597,4598],{},"sops-nix",", for secrets that live encrypted in the repo",[36,4601,4602,4605],{},[28,4603,4604],{},"restic"," backups",[36,4607,4608,4611],{},[28,4609,4610],{},"PostgreSQL"," for the next app",[36,4613,4614,4617],{},[28,4615,4616],{},"comin",", if I ever have more than one server",[36,4619,4620,4622],{},[28,4621,4395],{},", for the pure Nix version of the site",[10,4624,4625,4626,4628,4629,4631],{},"If you're thinking about trying NixOS on Oracle's free tier: do it. Test the serial console first, keep ",[122,4627,1542],{}," in your back pocket, and run ",[122,4630,1366],{}," before every deploy.",[10,4633,4634],{},[377,4635,4636,4637,204],{},"The config is at ",[17,4638,4640],{"href":79,"rel":4639,"target":22},[21],"github.com\u002Fdesignbyfaizi\u002Fnixos-config",[10,4642,4643],{},[67,4644],{"alt":4645,"height":4646,"src":4647,"width":4646,"title":4648},"A small hand-drawn character in a black hoodie with rose drawstrings sits on the floor, blushing, under handwritten text that says thank you for reading, with a little rose heart",420,"\u002Fuploads\u002Fblog\u002Fnixos-thanks.webp","See you in the next one",[4650,4651,4652],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":421,"searchDepth":439,"depth":439,"links":4654},[4655,4658,4664,4670,4674,4678,4679,4683,4691,4692,4693],{"id":94,"depth":433,"text":95,"children":4656},[4657],{"id":175,"depth":439,"text":176},{"id":232,"depth":433,"text":233,"children":4659},[4660,4661,4662,4663],{"id":239,"depth":439,"text":240},{"id":269,"depth":439,"text":270},{"id":283,"depth":439,"text":284},{"id":398,"depth":439,"text":399},{"id":518,"depth":433,"text":519,"children":4665},[4666,4667,4668,4669],{"id":605,"depth":439,"text":606},{"id":762,"depth":439,"text":763},{"id":909,"depth":439,"text":910},{"id":1340,"depth":439,"text":1341},{"id":1385,"depth":433,"text":1386,"children":4671},[4672,4673],{"id":1451,"depth":439,"text":1452},{"id":1619,"depth":439,"text":1620},{"id":1702,"depth":433,"text":1703,"children":4675},[4676,4677],{"id":1827,"depth":439,"text":1828},{"id":1887,"depth":439,"text":1888},{"id":2175,"depth":433,"text":2176},{"id":2429,"depth":433,"text":2430,"children":4680},[4681,4682],{"id":2436,"depth":439,"text":2437},{"id":2526,"depth":439,"text":2527},{"id":2721,"depth":433,"text":2722,"children":4684},[4685,4686,4687,4688,4689,4690],{"id":2743,"depth":439,"text":2744},{"id":2812,"depth":439,"text":2813},{"id":2860,"depth":439,"text":2861},{"id":2923,"depth":439,"text":2924},{"id":3815,"depth":439,"text":3816},{"id":3986,"depth":439,"text":3987},{"id":4298,"depth":433,"text":4299},{"id":4428,"depth":433,"text":4429},{"id":4587,"depth":433,"text":4588},"2026-11-09","How I replaced Ubuntu with NixOS on Oracle Cloud's free Ampere server, then ran a Nuxt and Bun site on it without the need of Docker","md","\u002Fuploads\u002Fblog\u002Fnixos-oracle-cover.jpg",{},"\u002Fblog\u002Fnixos-on-oracle-cloud","---\ntitle: NixOS on Oracle Cloud's Free ARM Server\ndescription: How I replaced Ubuntu with NixOS on Oracle Cloud's free Ampere server, then ran a Nuxt and Bun site on it without the need of Docker\nfeatured_image: \u002Fuploads\u002Fblog\u002Fnixos-oracle-cover.jpg\ntags:\n  - nixos\n  - oracle-cloud\n  - self-hosting\n  - nuxt\npublished: true\ncreated_at: 2026-11-09\nupdated_at: 2026-11-09\n---\n\nI have been deploying my side projects with Dokploy. It's a nice open-source Vercel-like dashboard built on top of docker that you can install on any machine. Write a Dockerfile, push, done. It works but the abstraction makes it hard to learn what's going on underneath.\n\nSo I got a free ARM instance on [Oracle Cloud Infrastructure (OCI)](https:\u002F\u002Fwww.oracle.com\u002Fde\u002Fcloud\u002F){target=\"_blank\"} and set myself a goal:\n\n**Learn NixOS by running real things on it, without Docker.** This post covers everything it took:\n\n1. Replacing the Ubuntu image with NixOS, using **nixos-anywhere**\n2. A small flake config deployed from my laptop with one command\n3. Caddy with automatic HTTPS (Can be replaced with Nginx or Traefik)\n4. Publishing the config while keeping my app private\n5. Deploying a **Nuxt 4 and Bun** site as a sandboxed systemd service, with a **deploy on every git push**\n\nI'll include the mistakes too. There were plenty.\n\n![A \"Chat, am I cooked?\" meme: a livestream of the NixOS install stuck after \"machine will boot into nixos in 6s\", stamped Instance unresponsive, while the stream chat says he's cooked and skill issue, until a moderator points out that disko never ran and a force reboot fixes it](\u002Fuploads\u002Fblog\u002Fnixos-cooked.webp \"Me after kexec left Ubuntu with no disk and no network\"){width=\"560\" height=\"463\"}\n\nYou can check my config repo [here](https:\u002F\u002Fgithub.com\u002Fdesignbyfaizi\u002Fnixos-config){target=\"_blank\"}. Here is the setup we are heading to. Keep this picture in mind.\n\n![Diagram of the finished setup: three lanes lead into the server through two firewalls, Oracle's security list and the NixOS firewall, which only open ports 22, 80 and 443. My laptop reaches sshd over SSH with just deploy, GitHub reaches mysite-deploy over SSH on every push, and visitors reach Caddy over HTTPS. Below, the three GitHub repos: the public nixos-config holds the base system and is imported by the private nixos-infra, which I deploy from, and the private app repo runs the GitHub Action](\u002Fuploads\u002Fblog\u002Fnixos-overview.webp){width=\"1600\" height=\"2124\"}\n\n## The machine\n\n|  |  |\n| --- | --- |\n| Shape | `VM.Standard.A1.Flex` (Ampere ARM), **2 OCPU \u002F 12 GB** |\n| CPU | Neoverse-N1, `aarch64-linux` |\n| Disk | 46.6 GB boot volume, paravirtualized (virtio-scsi) |\n| Firmware | UEFI, Secure Boot off |\n| Region | Frankfurt |\n\n![The Oracle Cloud home page on a Free Trial account, showing the instance khakcsar running and the VCN nixos-network](\u002Fuploads\u002Fblog\u002Fnixos-shot-oci-home.webp){width=\"1775\" height=\"1040\"}\n\n### The free tier is smaller than most guides say\n\nIf you look at older tutorials, they say that you get **4 OCPU \u002F 24 GB** of ARM. Oracle's [Always Free page](https:\u002F\u002Fdocs.oracle.com\u002Fen-us\u002Fiaas\u002FContent\u002FFreeTier\u002Ffreetier_topic-Always_Free_Resources.htm){target=\"_blank\"} now says:\n\n> All tenancies get the first 1,500 OCPU hours and 9,000 GB hours per month for free [...] For Always Free tenancies, this is equivalent to **2 OCPUs and 12 GB of memory**.\n\nMy account was still on it Free Trial so Oracle happily let me creaate a 4\u002F24 instance. That's double the free allowance and the page above doesn't even say what happens to oversized instances when the trial ends. I resized it to 2\u002F12 (**Instance → Edit → Shape**, one reboot, no reinstall) before doing anything else.\n\nTwo more rules from the same page are worth sharing:\n\n- The Always Free instances **must be in your home region**.\n- **Idle instances are reclaimed:** an instance is taken back if the CPU (95th percentile), network and memory all stay below 20% over 7 days.\n\n## Part 1: Preparation\n\nBefore doing anything destructive, we need to perform some checkups.\n\n### Make sure that the serial console works\n\nIf SSH or networking ever breaks, the serial console is the only way in. Test it BEFORE you need it:\n\n**Instance → OS Management → Console connection → Launch Cloud Shell connection**, then press Enter a few times. You should get a `login:` prompt.\n\n![Oracle Cloud Shell opening a console connection to the instance, which reaches the state ACTIVE](\u002Fuploads\u002Fblog\u002Fnixos-shot-serial-console.webp){width=\"1775\" height=\"1040\"}\n\nUbuntu's default user has no password so you can't actually log in there. That's why my NixOS config gives the admin user a password **only for the serial console**. SSH stays key-only.\n\n### Back up the boot volume\n\n**Storage → Boot volume → Backups → Create** (Full). It doesn't use the VM's disk and the **Always Free** tier includes 5 volume backups. This is actually pretty cool because if things go badly, you can just restore in onto the same instance. \n\n### Open ports 80 and 443 in Oracle's firewall\n\nOracle Cloud Infrastructure (OCI) has a cloud firewall in front of the VM. A port has to be open there AND in the OS firewall.\n\nThe VCN wizard in the OCI dashboard creates two subnets: public (`10.0.0.0\u002F24`) and private (`10.0.1.0\u002F24`). The instance lives in the public one.\n\n![The VCN's subnets page listing a public subnet 10.0.0.0\u002F24 and a private subnet 10.0.1.0\u002F24](\u002Fuploads\u002Fblog\u002Fnixos-shot-vcn-subnets.webp){width=\"1775\" height=\"1040\"}\n\n**Networking → VCN → public subnet → Security → Default Security List → Add Ingress Rules:**\n\n| Field | Value |\n| --- | --- |\n| Stateless | off |\n| Source CIDR | `0.0.0.0\u002F0` |\n| IP Protocol | TCP |\n| **Source** Port Range | **empty** |\n| Destination Port Range | `80,443` |\n\nMake sure NOT to put 80 and 443 in _Source_ Port Range. The source port is the visitor's random port so the rule never matches. Oracle splits `80,443` into two rows when it saves:\n\n![The Default Security List with ingress rules for port 22, two ICMP rules, and the new rules for ports 80 and 443](\u002Fuploads\u002Fblog\u002Fnixos-shot-security-list.webp){width=\"1775\" height=\"1040\"}\n\nThere's also no rule for ICMP type 8, so for example `ping` command won't work. That's normal.\n\n### Install Nix on the laptop\n\nMy laptop runs Arch (btw) and **it stays Arch.** It only needs **Nix, the package manager**. Nix lives in `\u002Fnix` and never touches the rest of my laptop's system. Here is how I installed it:\n\n```fish\nsudo pacman -S --needed nix\nsudo systemctl enable --now nix-daemon.service\nmkdir -p ~\u002F.config\u002Fnix\necho \"experimental-features = nix-command flakes\" >> ~\u002F.config\u002Fnix\u002Fnix.conf\n```\n\nTwo Arch surprises (Nix 2.35):\n\n1. **There is no `nix-users` group** (`usermod: group 'nix-users' does not exist`). In older guides and tutorials, they tell you to join it but you don't need to. The daemon socket is world-accessible and `allowed-users` defaults to `*`.\n2. `\u002Fnix\u002Fstore` didn't exist, so the first command failed with `error: opening file \"\u002Fnix\u002Fstore\": No such file or directory`. The fix:\n\n```fish\nsudo mkdir -p \u002Fnix\u002Fstore\nsudo chown root:nixbld \u002Fnix\u002Fstore\nsudo chmod 1775 \u002Fnix\u002Fstore        # sticky bit + group-writable for the build users\nsudo systemctl restart nix-daemon\nnix run nixpkgs#hello              # Hello, world!\n```\n\n## Part 2: The config\n\nThe final layout:\n\n```plain\nnixos-config\u002F\n├── flake.nix\n├── flake.lock\n├── hosts\u002F\n│   └── khakcsar\u002F\n│       ├── default.nix                 # the system\n│       ├── disko.nix                   # the disk layout\n│       └── hardware-configuration.nix  # generated during install\n└── services\u002F\n    └── caddy.nix\n```\n\n> **A heads-up before you copy this:** I'm new to Nix, and I don't think this is the best way to structure a NixOS config. It's simply the layout that made sense to me while learning. I also split it into a public and a private repo which is also not a standard afaik. I don't know yet what the usual approach looks like so expect the folders and repos to change as I learn more.\n\n**Rule number one of flakes: Nix only sees files tracked by git.** Create a file, forget `git add`, and Nix acts like the file doesn't exist. Edits to files that are already tracked are picked up without adding them again. I ended up forgetting this several times.\n\n### flake.nix\n\n```nix\n{\n  description = \"khakcsar - NixOS on Oracle Cloud A1\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-26.05\";\n\n    disko.url = \"github:nix-community\u002Fdisko\";\n    disko.inputs.nixpkgs.follows = \"nixpkgs\";   # don't fetch a second nixpkgs\n  };\n\n  outputs = { self, nixpkgs, disko, ... }: {\n    # The base system as a module, so other flakes can import it (see below)\n    nixosModules.khakcsar = {\n      imports = [ disko.nixosModules.disko .\u002Fhosts\u002Fkhakcsar ];\n    };\n\n    nixosConfigurations.khakcsar = nixpkgs.lib.nixosSystem {\n      system = \"aarch64-linux\";\n      modules = [ self.nixosModules.khakcsar ];\n    };\n  };\n}\n```\n\n> **Bug:** my first version said `nixosConfiguration` (no **s**). Flake outputs have no schema, so nothing complains until a tool goes looking for `nixosConfigurations.khakcsar` and can't find it. Beware of the typos.\n\n![An \"Only 1% of devs can spot the difference\" meme: two flake.nix snippets side by side, one with nixosConfiguration and one with nixosConfigurations, the missing s circled in red. Nix raises no error at first, then nixos-anywhere fails because the flake does not provide nixosConfigurations.khakcsar. The punchline: it's literally one letter](\u002Fuploads\u002Fblog\u002Fnixos-typo.webp \"Roses are red. I hate typos. Poetry's hard. NixOS\"){width=\"560\" height=\"488\"}\n\n### disko.nix\n\n```nix\n{\n  disko.devices.disk.main = {\n    type = \"disk\";\n    device = \"\u002Fdev\u002Fsda\";\n    content = {\n      type = \"gpt\";\n      partitions = {\n        ESP = {\n          size = \"512M\";\n          type = \"EF00\";\n          content = {\n            type = \"filesystem\";\n            format = \"vfat\";\n            mountpoint = \"\u002Fboot\";\n            mountOptions = [ \"umask=0077\" ];\n          };\n        };\n        root = {\n          size = \"100%\";\n          content = { type = \"filesystem\"; format = \"ext4\"; mountpoint = \"\u002F\"; };\n        };\n      };\n    };\n  };\n}\n```\n\nThe 512 MB ESP matters. A **generation** can be considered a version of your system, and you can roll back to a previous one if needed. **systemd-boot** keeps a kernel and initrd **per generation** in the ESP (about 50 to 60 MB each on ARM), and generations that use the same kernel share one copy. Ubuntu's 99 MB EFI partition would fill up fast. There's no swap partition. zram handles that.\n\n### default.nix\n\n```nix\n{ modulesPath, pkgs, ... }:\n\nlet\n  sshKey = \"ssh-ed25519 AAAA... you@laptop\";\nin\n{\n  imports = [\n    (modulesPath + \"\u002Fprofiles\u002Fqemu-guest.nix\")   # virtio drivers: without this, no \u002Fdev\u002Fsda at boot\n    .\u002Fdisko.nix\n    .\u002Fhardware-configuration.nix\n    ..\u002F..\u002Fservices\u002Fcaddy.nix\n  ];\n\n  # Boot\n  boot.loader.systemd-boot.enable = true;\n  boot.loader.systemd-boot.configurationLimit = 10;     # protects the 512M ESP\n  boot.loader.efi.canTouchEfiVariables = true;\n  boot.kernelParams = [ \"console=ttyAMA0\" \"console=tty1\" ];  # ttyAMA0 = the ARM serial port OCI's console reads\n\n  # Network\n  networking.hostName = \"khakcsar\";\n  networking.useDHCP = true;\n  networking.firewall = {\n    enable = true;\n    allowedTCPPorts = [ 22 80 443 ];\n  };\n  time.timeZone = \"UTC\";\n\n  # SSH: keys only, no root\n  services.openssh = {\n    enable = true;\n    settings = {\n      PasswordAuthentication = false;\n      KbdInteractiveAuthentication = false;\n      PermitRootLogin = \"no\";\n    };\n  };\n\n  # Users: fully declarative\n  users.users.khakcsar = {\n    isNormalUser = true;\n    extraGroups = [ \"wheel\" ];\n    hashedPasswordFile = \"\u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash\";   # serial console only (see below)\n    openssh.authorizedKeys.keys = [ sshKey ];\n  };\n  users.mutableUsers = false;\n  security.sudo.wheelNeedsPassword = false;\n\n  # Nix itself\n  nix.settings = {\n    experimental-features = [ \"nix-command\" \"flakes\" ];\n    trusted-users = [ \"@wheel\" ];\n  };\n  nix.gc = { automatic = true; dates = \"weekly\"; options = \"--delete-older-than 14d\"; };\n  nix.optimise.automatic = true;\n\n  zramSwap.enable = true;\n\n  environment.systemPackages = with pkgs; [ git vim htop btop curl tree ncdu ];\n\n  system.stateVersion = \"26.05\";   # the release you FIRST installed. Never change it.\n}\n```\n\nThe lines specific to OCI are `qemu-guest.nix` (virtio disk and network drivers) and `console=ttyAMA0`. Without the second one the serial console you tested earlier stays blank on NixOS.\n\nFor the very first install, `hashedPasswordFile` doesn't exist yet because the server hasn't been installed. So for the first install I put the hash straight into the config with `hashedPassword`, and moved it out later.\n\n**Generating the hash.** On the laptop, run:\n\n```fish\nnix shell nixpkgs#mkpasswd -c mkpasswd -m yescrypt\n```\n\nIt asks for the password twice and prints something like `$y$j9T$....` That's a **hash**, not the password: it can check a password but can't be turned back into one. **yescrypt** is what modern Linux uses for `\u002Fetc\u002Fshadow` and it's deliberately slow to crack.\n\nThen paste the output into the config:\n\n```nix\nhashedPassword = \"$y$j9T$...\";   # first install only, see \"Sharing the config\"\n```\n\nThe `$` signs are fine inside a Nix string. Nix only treats `${` as special and the hash never contains it.\n\n**Test it before you need it.** After the install, log in on the serial console with this password once. That's the whole point of it. The moment SSH breaks, this is the only way in.\n\nEven a hash shouldn't be public. Anyone who has it can try guessing passwords offline for as long as they like. That's why I moved it into `hashedPasswordFile `outside the repo. More on that in the section about sharing the config.\n\n### Check before touching the server\n\n```fish\necho \"{ }\" > hosts\u002Fkhakcsar\u002Fhardware-configuration.nix   # placeholder; an empty *file* is a syntax error\ngit add -A\nnix eval .#nixosConfigurations.khakcsar.config.system.build.toplevel.drvPath\n```\n\n`nix eval` merges every module and type-checks every option **without building anything.** So it works fine on an x86 laptop for an ARM target. It's great at catching typos. My `user.users.khakcsar` failed right away with _\"The option `user` does not exist.\"_\n\n## Part 3: The install\n\n```fish\nnix run github:nix-community\u002Fnixos-anywhere -- \\\n  --flake .#khakcsar \\\n  --kexec https:\u002F\u002Fgithub.com\u002Fnix-community\u002Fnixos-images\u002Freleases\u002Fdownload\u002Fnixos-26.05\u002Fnixos-kexec-installer-noninteractive-aarch64-linux.tar.gz \\\n  --generate-hardware-config nixos-generate-config .\u002Fhosts\u002Fkhakcsar\u002Fhardware-configuration.nix \\\n  --build-on remote \\\n  -i ~\u002F.ssh\u002F\u003Cyour-key> \\\n  ubuntu@\u003Cinstance-ip>\n```\n\nnixos-anywhere SSH's into Ubuntu and uses `kexec` to swap the running kernel for a NixOS installer **in RAM**. It then partitions the disk with disko, builds the system, installs it, and reboots. With `--build-on remote`, the ARM server builds its own system so my x86 laptop never has to emulate ARM. Amazing right?\n\n![The six steps of nixos-anywhere, from SSH into Ubuntu through kexec, hardware scan, disko partitioning, building on the server, and rebooting into NixOS, with the point of no return marked before disko](\u002Fuploads\u002Fblog\u002Fnixos-install.webp){width=\"1600\" height=\"2284\"}\n\n### The --kexec flag: my first attempt hung\n\nWithout `--kexec,` nixos-anywhere used its default installer image (`nixos-installer-kexec-25.11pre-git`). It printed `machine will boot into nixos in 6s...`, reconnected a few times, and then:\n\n```plain\nssh: connect to host \u003Cinstance-ip> port 22: Connection timed out\n```\n\n![A T-rex labelled Oracle Cloud looms with its teeth bared behind a wide-eyed dog labelled My instance, who stares into the camera in panic](\u002Fuploads\u002Fblog\u002Fnixos-trex.webp \"I lowkey thought my instance was history\"){width=\"560\" height=\"490\"}\n\nThe serial console showed **Ubuntu** (its root partition `sda1`, its machine-id), spamming errors:\n\n```plain\nI\u002FO error, dev sda, sector 4016664 op 0x1:(WRITE)\nAborting journal on device sda1-8.\nEXT4-fs (sda1): Remounting filesystem read-only\n```\n\n![Oracle Cloud reporting that the instance is unresponsive, with launch options showing paravirtualized volumes and Secure Boot disabled](\u002Fuploads\u002Fblog\u002Fnixos-shot-instance-unresponsive.webp){width=\"1775\" height=\"1040\"}\n\nSo kexec had loaded the new kernel and shut down Ubuntu's disk and network devices but the jump into the new kernel never happened. Ubuntu kept running with no disk and no network.\n\nThe good news: **disko hadn't run yet** so the disk was untouched. A **force reboot** (Actions → Reboot → _Force_) brought Ubuntu back and calmed my inner soul. Passing the 26.05 image explicitly with `--kexec` worked on the first try. Secure Boot was off, so that wasn't the cause.\n\nOther people have installed on OCI with the default image without trouble, so this may depend on the instance or the kernel. If yours hanged on the same step, now you know what to do.\n\n**What a successful run looks like:**\n\n1. After `machine will boot into nixos in 6s…`, the console's kernel timestamps **restart at `[ 0.000000]`**. That means kexec worked.\n2. The hardware config file gets generated, then disko wipes and partitions the disk (the point of no return), then the build and install run.\n3. `installation finished!`, then `### Done! ###`\n\nI landed in the UEFI boot menu at first, because I'd pressed a key in the console. **Linux Boot Manager** is systemd-boot.\n\n![The UEFI boot device menu listing Linux Boot Manager, the Oracle block volume, firmware setup, the EFI shell and PXE](\u002Fuploads\u002Fblog\u002Fnixos-shot-uefi-boot-menu.webp){width=\"820\" height=\"520\"}\n\n![The serial console welcoming NixOS 26.05 on aarch64 and a successful login as khakcsar](\u002Fuploads\u002Fblog\u002Fnixos-shot-first-boot.webp){width=\"1775\" height=\"1025\"}\n\n> **Careful:** type the **username** at `login:`. I typed my password there and it showed up in plain text 💀. That's why I don't have an unedited screenshot of that moment.\n\n### After the install\n\nThe server now has a new SSH host key so the laptop refuses to connect (_REMOTE HOST IDENTIFICATION HAS CHANGED_). Remove the old Ubuntu entry, then log in as the new user:\n\n```fish\nssh-keygen -R \u003Cinstance-ip>\n# ~\u002F.ssh\u002Fconfig: User ubuntu → User khakcsar\nssh khakcsar\n```\n\nThat's the server's key not yours. Your own key pair is untouched. If you want to be thorough, compare the fingerprint SSH shows you with `ssh-keygen -lf \u002Fetc\u002Fssh\u002Fssh_host_ed25519_key.pub`, run over the serial console.\n\n```console\n[khakcsar@khakcsar:~]$ nixos-version\n26.05.20261102.7dcdd87 (Yarara)\n[khakcsar@khakcsar:~]$ systemctl --failed\n0 loaded units listed.\n[khakcsar@khakcsar:~]$ df -h \u002F \u002Fboot\n\u002Fdev\u002Fsda2        46G  2.5G   41G   6% \u002F\n\u002Fdev\u002Fsda1       511M   87M  425M  17% \u002Fboot\n```\n\nA whole OS in 2.5 GB. \n\n## Part 4: Deploying changes from the laptop\n\n```fish\nnix run --inputs-from . nixpkgs#nixos-rebuild -- switch \\\n  --flake .#khakcsar --target-host khakcsar --build-host khakcsar --sudo\n```\n\n| Part | Why |\n| --- | --- |\n| `--inputs-from . nixpkgs#nixos-rebuild` | Uses the `nixos-rebuild` from my locked nixpkgs. Nothing installed on Arch |\n| `--target-host` | **Where to activate.** Without it, the laptop is the target. Missing it is dangerous |\n| `--build-host` | Build natively on the ARM server |\n| `--sudo` | Activate with sudo on the server |\n\nYou may see `warning: could not re-exec in a newer version of nixos-rebuild`. nixos-rebuild tries to restart as the copy inside the target config, which is an ARM binary the x86 laptop can't run. It's harmless; `--no-reexec` silences it.\n\n**The first deploy surprised me:** I added a package `tree` to `systemPackages`, deployed, and it was available in my **already open SSH session**. `PATH` includes `\u002Frun\u002Fcurrent-system\u002Fsw\u002Fbin`, and activation just flips the `\u002Frun\u002Fcurrent-system` symlink in one step. Only services whose config changed get restarted. `sshd` hadn't changed, so my session survived.\n\n### Rollback\n\n```fish\nnix run --inputs-from . nixpkgs#nixos-rebuild -- switch --rollback --flake .#khakcsar --target-host khakcsar --sudo\n```\n\nA rollback builds nothing: the previous generation is already on the server. `tree` vanished, and deploying again brought it back.\n\nFun fact: deploying an identical config **doesn't create a new generation**. The new system has the same store path as the newest generation, so Nix just reuses it.\n\n![How deploy, test and rollback move the current and boot generations, and which of them build anything](\u002Fuploads\u002Fblog\u002Fnixos-generations.webp){width=\"1600\" height=\"1456\"}\n\nMy safety nets:\n\n1. Bad deploy, but SSH still works: `switch --rollback`.\n2. Bad deploy that broke SSH or the network: serial console, reboot, and pick an older generation in the systemd-boot menu.\n3. A risky firewall, SSH or network change: deploy with **`test`** instead of `switch`. It activates without becoming the boot default, so a reboot undoes it.\n\n### A justfile so I never forget --target-host\n\n[just](https:\u002F\u002Fgithub.com\u002Fcasey\u002Fjust){target=\"_blank\"} is a small command runner. You write named recipes in a file called `justfile`, and `just deploy` runs the matching one. Think of it as a `Makefile` without the build-system baggage.\n\n**Installing it on the laptop.** Pick one:\n\n```bash\n# With Nix, like everything else here (on older Nix the command is \"nix profile install\")\nnix profile add nixpkgs#just\n\n# Or with Arch's package manager\nsudo pacman -S just\n\n# Or without installing anything, once\nnix run nixpkgs#just -- deploy\n```\n\nI installed it through pacman because Arch (btw)\n\nIf your shell says `just: command not found` after the Nix install, `~\u002F.nix-profile\u002Fbin` isn't on your `PATH` yet. Open a new terminal, or add it to your shell's config.\n\n**Using it.** Save the file below as `justfile` in the root of the config repo, next to `flake.nix`. Then `just --list` shows every recipe with its comment, and `just deploy` runs one. Recipe lines must be indented, the same way in the whole file.\n\n```just\nhost := \"khakcsar\"\nrebuild := \"nix run --inputs-from . nixpkgs#nixos-rebuild -- --no-reexec\"\n\n# Build on the server and switch to it (+ make it the boot default)\ndeploy:\n    {{ rebuild }} switch --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n\n# Activate without making it the boot default: a reboot undoes it\ntest:\n    {{ rebuild }} test --flake .#{{ host }} --target-host {{ host }} --build-host {{ host }} --sudo\n\n# Back to the previous generation (nothing is built)\nrollback:\n    {{ rebuild }} switch --rollback --flake .#{{ host }} --target-host {{ host }} --sudo\n\n# Evaluate the whole config locally (catches typos, builds nothing)\ncheck:\n    nix eval .#nixosConfigurations.{{ host }}.config.system.build.toplevel.drvPath\n\n# List system generations on the server\ngenerations:\n    ssh {{ host }} sudo nix-env --list-generations -p \u002Fnix\u002Fvar\u002Fnix\u002Fprofiles\u002Fsystem\n\n# Update flake.lock\nupdate:\n    nix flake update\n```\n\nMy workflow is now: **edit, `just check`, `just deploy`, `git commit`.**\n\n## Part 5: Caddy, HTTPS and a free domain\n\nTwo firewalls guard every port: **OCI's Security List** and **`networking.firewall`**. When something doesn't respond, `curl` tells you which layer to look at:\n\n| `curl` says | Meaning |\n| --- | --- |\n| Times out | Blocked, usually by the OCI Security List |\n| `Connection refused` | Got through, but nothing is listening |\n| Certificate error | Reached Caddy, but no certificate yet: check `journalctl -u caddy` |\n\n![A request to port 443 passing the visitor, Oracle's Security List, the VM firewall and Caddy, with what each curl error means](\u002Fuploads\u002Fblog\u002Fnixos-firewalls.webp){width=\"1600\" height=\"1558\"}\n\nI didn't own a domain for testing, so I used [sslip.io](https:\u002F\u002Fsslip.io){target=\"_blank\"}: `203-0-113-10.sslip.io` resolves to `203.0.113.10`, so you put your own IP in place of that one. **Let's Encrypt** issues real certificates for those names.\n\n`services\u002Fcaddy.nix`:\n\n```nix\n{ pkgs, ... }:\n\nlet\n  domain = \"203-0-113-10.sslip.io\";   # your IP with dashes\n\n  # The website is built as a package in the Nix store\n  site = pkgs.writeTextDir \"index.html\" ''\n    \u003C!doctype html>\n    \u003Chtml>\n      \u003Chead>\u003Cmeta charset=\"utf-8\">\u003Ctitle>khakcsar\u003C\u002Ftitle>\u003C\u002Fhead>\n      \u003Cbody>\u003Ch1>Hello from NixOS on Oracle Cloud 🗿\u003C\u002Fh1>\u003C\u002Fbody>\n    \u003C\u002Fhtml>\n  '';\nin\n{\n  services.caddy = {\n    enable = true;\n    virtualHosts.${domain}.extraConfig = ''\n      root * ${site}\n      file_server\n    '';\n  };\n}\n```\n\n`pkgs.writeTextDir` puts the page in `\u002Fnix\u002Fstore\u002FHASH-index.html\u002F`. Change the HTML and you get a new store path; roll back and the old page comes back. **The website is part of the system generation.** Caddy gets a Let's Encrypt certificate, redirects HTTP to HTTPS and renews automatically, with zero TLS config.\n\n![The Caddy test page next to a terminal listing three system generations, and the config repo on the laptop](\u002Fuploads\u002Fblog\u002Fnixos-shot-generations-caddy.webp \"NixOS serving a static page from my instance 🗿\"){width=\"1802\" height=\"1142\"}\n\n> **Bug:** my first `caddy.nix` started with `{ pkgs, ... };`. It needs a **colon**, because a module is a function. These typos will be the end of me.\n\n## Part 6: Sharing the config, but not everything\n\nSomeone on Reddit asked for my config, so I wanted it public. Two problems.\n\n### 1. The password hash was in every commit\n\nA yescrypt hash is safe to store but public means anyone can try to crack it offline. First I moved it into a file only root can read on the server. You can copy it straight out of `\u002Fetc\u002Fshadow`:\n\n```bash\nsudo sh -c 'umask 077; mkdir -p \u002Fvar\u002Flib\u002Fsecrets && getent shadow khakcsar | cut -d: -f2 > \u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash'\n```\n\nThen `hashedPassword = \"…\";` became `hashedPasswordFile = \"\u002Fvar\u002Flib\u002Fsecrets\u002Fkhakcsar-password-hash\";`. Create the file before deploying, or the console password disappears.\n\nThen I scrubbed it from history. I wanted to keep my commits, not squash everything into one, so I used **git-filter-repo**:\n\n```fish\ncp -r nixos-config nixos-config-backup                 # backup first (it still contains the hash; never push it)\nprintf '%s\\n' 'regex:\\$y\\$[^\"]*==>REMOVED' > ~\u002Ffilter-rules.txt\ncd nixos-config\nnix shell nixpkgs#git-filter-repo -c git filter-repo --replace-text ~\u002Ffilter-rules.txt --force\ngit log --all -p | grep -c 'y\\$j9T'                    # 0\ngit cat-file --batch-all-objects --batch | grep -ca 'y\\$j9T'   # 0, including unreachable objects\n```\n\nOld commits now say `hashedPassword = \"REMOVED\";`. Every commit ID changes, so do this **before** the first push.\n\n### 2. I didn't want to publish which apps I run\n\nThe solution: **two flakes.**\n\n- **Public `nixos-config`:** the base system, exported as `nixosModules.khakcsar` (see `flake.nix` above).\n- **Private `nixos-infra`:** imports the public flake and adds my apps. The `justfile` lives here, and **this is the only repo I deploy from.**\n(Again, I'm no expert so I'm not aware of the standard way to create this config so if you know a better way to handle this usecase then let me know via email or my socials)\n\n![The public nixos-config flake exports the base module, the private nixos-infra flake imports it and adds the apps, and the server receives one system with the same derivation hash as before the split](\u002Fuploads\u002Fblog\u002Fnixos-flakes.webp){width=\"1600\" height=\"1800\"}\n\n```nix\n# nixos-infra\u002Fflake.nix (private)\n{\n  inputs = {\n    base.url = \"github:designbyfaizi\u002Fnixos-config\";\n    nixpkgs.follows = \"base\u002Fnixpkgs\";               # one nixpkgs, owned by the base\n    nixpkgs-unstable.url = \"github:NixOS\u002Fnixpkgs\u002Fnixos-unstable\";\n  };\n\n  outputs = { self, nixpkgs, nixpkgs-unstable, base, ... }: {\n    nixosConfigurations.khakcsar = nixpkgs.lib.nixosSystem {\n      system = \"aarch64-linux\";\n      specialArgs = {\n        pkgs-unstable = nixpkgs-unstable.legacyPackages.aarch64-linux;\n      };\n      modules = [\n        base.nixosModules.khakcsar\n        .\u002Fservices\u002Fmysite.nix\n      ];\n    };\n  };\n}\n```\n\nChanging the base means pushing the public repo, then running `nix flake update base` in the private one, then deploying.\n\n**My favourite trick from this whole project:** after each refactor, I ran `just check` and confirmed the **`.drv` hash was identical**. Moving files or splitting flakes doesn't change what gets built, so a matching hash proves the refactor changed nothing, giving me an inner peace moment.\n\nOne more thing: if you're hiding hostnames for privacy, don't count on it. **Every Let's Encrypt certificate is published** in Certificate Transparency logs ([crt.sh](https:\u002F\u002Fcrt.sh){target=\"_blank\"}). The private repo hides _how_ things run, not _that_ they exist.\n\n## Part 7: A Nuxt and Bun app without Docker\n\nThe app is a Nuxt 4 site: Nitro with `preset: \"bun\"`, Nuxt Content (SQLite), `@nuxt\u002Fimage` (sharp), shadcn-vue, and **Sveltia CMS**, which commits content edits straight to GitHub. Pages are prerendered, but a server still has to run for the API route and for resizing images on demand.\n\nOn Dokploy, it was a Dockerfile plus a webhook. Here's the NixOS version.\n\n### DNS: a wildcard for test apps\n\nMy primary domain was connected to my main vps, so I decided to create .nix subdomains for this server. In Cloudflare (DNS only, grey cloud):\n\n| Name | Type | Content |\n| --- | --- | --- |\n| `nix` | A | `\u003Cinstance-ip>` |\n| `*.nix` | A | `\u003Cinstance-ip>` |\n\nNow `anything.nix.example.com` reaches the server. I originally wanted `*-nix.example.com`, but **a DNS wildcard has to be a whole label**, so `*.nix` is the closest you can get. Caddy still gets one normal certificate per hostname you configure. You don't need a wildcard certificate.\n\n### Gotcha 1: the Bun version\n\n`bun install --frozen-lockfile` with the Bun in nixos-26.05 (1.3.13):\n\n```plain\nwarn: Ignoring lockfile\nerror: lockfile had changes, but lockfile is frozen\n```\n\nMy `bun.lock` is in **v2 format**, which **Bun 1.4** writes. nixos-unstable has 1.4.4, so the system stays on stable and I take _just Bun_ from unstable. That's the `pkgs-unstable` in the private flake above.\n\n### Gotcha 2: prebuilt native modules\n\n`sharp` and Tailwind's `oxide` ship prebuilt `.node` binaries:\n\n```console\n$ readelf -d node_modules\u002F@img\u002Fsharp-linux-x64\u002Flib\u002F*.node | grep NEEDED\n (NEEDED)  Shared library: [libvips-cpp.so.8.18.7]\n (NEEDED)  Shared library: [libstdc++.so.6]\n (NEEDED)  Shared library: [libgcc_s.so.1]\n ...\n```\n\nNixOS doesn't keep `libstdc++` in `\u002Fusr\u002Flib`, so I point them at Nix's copy with `LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]`. It's not elegant, but it works. More on that at the end.\n\n### The module: services\u002Fmysite.nix\n\n```nix\n{ pkgs, pkgs-unstable, lib, ... }:\n\nlet\n  domain = \"mysite.nix.example.com\";\n  repo = \"git@github.com:you\u002Fmysite.git\";\n  stateDir = \"\u002Fvar\u002Flib\u002Fmysite\";\n  port = 3000;\n\n  bun = pkgs-unstable.bun;                                    # bun.lock v2 needs Bun 1.4\n  libPath = lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];    # libstdc++ \u002F libgcc_s for sharp & oxide\n\n  # Fetch the latest commit, build it into its own release folder, switch to it\n  deployScript = pkgs.writeShellApplication {\n    name = \"mysite-deploy\";\n    runtimeInputs = [ bun pkgs.git pkgs.openssh pkgs.coreutils pkgs.findutils ];\n    text = ''\n      cd ${stateDir}\n      if [ ! -d repo ]; then\n        git clone --branch main ${repo} repo\n      fi\n      cd repo\n      git fetch origin main\n      git reset --hard origin\u002Fmain\n      git clean -fdx -e node_modules\n\n      rev=$(git rev-parse --short HEAD)\n      release=${stateDir}\u002Freleases\u002F$rev\n\n      if [ -d \"$release\" ]; then\n        echo \"Release $rev is already built\"\n      else\n        bun install --frozen-lockfile\n        bun run build\n        mkdir -p \"$release\"\n        cp -r .output \"$release\u002F\"\n      fi\n\n      ln -sfn \"$release\" ${stateDir}\u002Fcurrent.new\n      mv -T ${stateDir}\u002Fcurrent.new ${stateDir}\u002Fcurrent     # atomic switch\n\n      # keep the 5 newest releases\n      find ${stateDir}\u002Freleases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' \\\n        | sort -rn | tail -n +6 | cut -d' ' -f2- | xargs -r rm -rf\n\n      echo \"Deployed $rev\"\n    '';\n  };\n\n  # The only thing the CI key may run: start the deploy, stream its log back\n  ciTrigger = pkgs.writeShellApplication {\n    name = \"mysite-ci-trigger\";\n    runtimeInputs = [ pkgs.coreutils pkgs.systemd ];\n    text = ''\n      since=$(date '+%Y-%m-%d %H:%M:%S')\n      status=0\n      \u002Frun\u002Fwrappers\u002Fbin\u002Fsudo ${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy || status=$?\n      journalctl -u mysite-deploy --since \"$since\" --no-pager --output cat || true\n      exit \"$status\"\n    '';\n  };\n\n  ciKey = \"ssh-ed25519 AAAA... github-actions deploy\";\nin\n{\n  users.users.mysite = {\n    isSystemUser = true;\n    group = \"mysite\";\n    home = stateDir;\n    createHome = true;\n  };\n  users.groups.mysite = { };\n\n  # Verify github.com when cloning (background jobs can't answer the \"yes\u002Fno\" prompt)\n  programs.ssh.knownHosts.\"github.com\".publicKey =\n    \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\";\n\n  # The site\n  systemd.services.mysite = {\n    description = \"My Nuxt site (Bun)\";\n    wantedBy = [ \"multi-user.target\" ];\n    after = [ \"network.target\" ];\n    unitConfig.ConditionPathExists = \"${stateDir}\u002Fcurrent\u002F.output\u002Fserver\u002Findex.mjs\";  # wait for the first build\n    environment = {\n      NODE_ENV = \"production\";\n      HOST = \"127.0.0.1\";\n      PORT = toString port;\n      LD_LIBRARY_PATH = libPath;\n    };\n    serviceConfig = {\n      User = \"mysite\";\n      Group = \"mysite\";\n      WorkingDirectory = \"${stateDir}\u002Fcurrent\";\n      ExecStart = \"${lib.getExe bun} .output\u002Fserver\u002Findex.mjs\";\n      Restart = \"on-failure\";\n      RestartSec = 5;\n      NoNewPrivileges = true;\n      PrivateTmp = true;\n      ProtectSystem = \"strict\";          # whole filesystem read-only…\n      ProtectHome = true;\n      ReadWritePaths = [ stateDir ];     # …except its own folder\n    };\n  };\n\n  # The build\u002Fdeploy job\n  systemd.services.mysite-deploy = {\n    description = \"Build and deploy my site from GitHub\";\n    environment = {\n      HOME = stateDir;\n      GIT_SSH_COMMAND = \"ssh -i ${stateDir}\u002F.ssh\u002Fid_ed25519 -o IdentitiesOnly=yes\";\n      LD_LIBRARY_PATH = libPath;\n    };\n    serviceConfig = {\n      Type = \"oneshot\";\n      User = \"mysite\";\n      Group = \"mysite\";\n      ExecStart = lib.getExe deployScript;\n      ExecStartPost = \"+${pkgs.systemd}\u002Fbin\u002Fsystemctl restart mysite.service\";  # \"+\" = this line runs as root\n      TimeoutStartSec = \"30min\";\n    };\n  };\n\n  # The user GitHub Actions logs in as: one forced command, one sudo rule\n  users.users.mysite-ci = {\n    isSystemUser = true;\n    group = \"mysite-ci\";\n    shell = pkgs.bash;                      # sshd runs forced commands through the login shell\n    extraGroups = [ \"systemd-journal\" ];    # to stream the deploy log back\n    openssh.authorizedKeys.keys = [\n      ''command=\"${lib.getExe ciTrigger}\",restrict ${ciKey}''\n    ];\n  };\n  users.groups.mysite-ci = { };\n\n  security.sudo.extraRules = [{\n    users = [ \"mysite-ci\" ];\n    commands = [{\n      command = \"${pkgs.systemd}\u002Fbin\u002Fsystemctl start mysite-deploy\";\n      options = [ \"NOPASSWD\" ];\n    }];\n  }];\n\n  services.caddy.virtualHosts.${domain}.extraConfig = ''\n    reverse_proxy 127.0.0.1:${toString port}\n  '';\n}\n```\n\nThat `services.caddy.virtualHosts` block lands in the **same** Caddy as the public repo's `caddy.nix`; NixOS merges modules. `writeShellApplication` runs **shellcheck** at build time, so bash mistakes fail the deploy, not the site. (I had `git rev-phase`, `--fdx` and `\"$release\"]` in my first draft.)\n\nHow it maps to Docker:\n\n| Dokploy \u002F Docker | NixOS |\n| --- | --- |\n| Dockerfile build stage | `deployScript` |\n| Container | `mysite.service` + systemd sandboxing |\n| Traefik + domain | the Caddy `virtualHosts` block |\n| Previous deployments | `releases\u002F\u003Csha>` + the `current` symlink |\n\n> **Never put secrets in a `.nix` file.** Everything Nix builds lands in `\u002Fnix\u002Fstore`, readable by every user on the machine, and that includes unit files with their `environment`. Use a file only root can read, or sops-nix.\n\n### A read-only deploy key\n\nThe repo is private. A **deploy key** belongs to one repo, not your account, and can be read-only:\n\n```bash\nsudo -u mysite mkdir -p -m 700 \u002Fvar\u002Flib\u002Fmysite\u002F.ssh        # owned by the service user; SSH rejects loose permissions\nsudo -u mysite ssh-keygen -t ed25519 -N \"\" -C \"server deploy key\" -f \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519\nsudo cat \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519.pub                # → repo → Settings → Deploy keys (write access OFF)\nsudo -u mysite ssh -i \u002Fvar\u002Flib\u002Fmysite\u002F.ssh\u002Fid_ed25519 -o IdentitiesOnly=yes -T git@github.com\n# Hi you\u002Fmysite! You've successfully authenticated...\n```\n\nFirst build: `journalctl -u mysite-deploy -f` in one terminal, `sudo systemctl start mysite-deploy` in another. A few minutes later:\n\n```plain\n● mysite.service - My Nuxt site (Bun)\n     Active: active (running)\n     Memory: 28.6M\nbun[9952]: Listening on http:\u002F\u002F127.0.0.1:3000\n```\n\n**About 29 MB of RAM** for the running site. I checked an on-demand AVIF resize from outside, which proves `sharp` works on NixOS.\n\n### Deploy on push: GitHub Actions with a locked-down key\n\nGitHub gets an SSH key to my server, so that key can do exactly one thing.\n\n![Six steps from a git push or CMS save to a live release: the GitHub Action, the forced command, the single sudo rule, the build into its own release folder, and the symlink swap](\u002Fuploads\u002Fblog\u002Fnixos-deploy.webp){width=\"1600\" height=\"2386\"}\n\n- `command=\"…\"` in `authorized_keys`: **every** connection runs the trigger, whatever was asked. `ssh … 'cat \u002Fetc\u002Fpasswd'` just deploys.\n- `restrict`: no terminal and no forwarding. If you test it interactively, you'll see `PTY allocation request failed`, which means it's working.\n- `systemctl start` on a oneshot **waits** for the job and returns its exit code, so GitHub shows a pass or a fail.\n\nIn the app repo, add two secrets: the CI private key, and the server's host key line (`ssh-keyscan -t ed25519 \u003Cinstance-ip>`, after checking its fingerprint matches the one you saw on first login). Then `.github\u002Fworkflows\u002Fdeploy-nixos.yml`:\n\n```yaml\nname: Deploy to NixOS\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:          # \"Run workflow\" button = manual redeploy\n\n# One deploy at a time: a push during a running deploy waits, then deploys the newest commit\nconcurrency:\n  group: deploy-nixos\n  cancel-in-progress: false\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    timeout-minutes: 40\n    steps:\n      - name: Build and deploy on the server\n        env:\n          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}\n          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}\n        run: |\n          install -d -m 700 ~\u002F.ssh\n          printf '%s\\n' \"$SSH_KEY\" > ~\u002F.ssh\u002Fdeploy_key\n          chmod 600 ~\u002F.ssh\u002Fdeploy_key\n          printf '%s\\n' \"$KNOWN_HOSTS\" > ~\u002F.ssh\u002Fknown_hosts\n          ssh -T -i ~\u002F.ssh\u002Fdeploy_key -o IdentitiesOnly=yes -o ServerAliveInterval=30 mysite-ci@\u003Cinstance-ip>\n```\n\n`concurrency` isn't optional here. `systemctl start` on a oneshot that's already running just joins it, so without it a push during a build would never get deployed.\n\n![The GitHub Actions log of the first automatic deploy: the Nuxt build completes in 52.8 seconds and the job succeeds in 1 minute 1 second](\u002Fuploads\u002Fblog\u002Fnixos-shot-github-deploy.webp){width=\"1315\" height=\"1005\"}\n\n**The first automatic deploy took 1 minute 1 second.** The Nuxt build itself took 52.8 s on 2 OCPUs and peaked at 3.1 GB. On one of my old 4 GB Dokploy server, that same build got killed for running out of memory under Bun, and I had to build under Node with a 768 MB heap cap.\n\nSveltia CMS saves are just commits to `main`, so **content edits deploy themselves**. I tested it, and that was the moment Dokploy stopped being necessary for this site.\n\n## Part 8: Did I deploy it the Nix way?\n\n**Partly. Honestly, it's a hybrid: managed by NixOS, not built by Nix.**\n\n**What is properly declarative and reproducible:**\n\n- The service, its user, the sandboxing, the reverse proxy and TLS\n- The deploy job, the CI user, the forced-command key and the single sudo rule\n- The **exact Bun version** pinned through `flake.lock`\n- All of it is versioned in git and rolls back with system generations\n\n**What isn't:**\n\n- The **app itself is built imperatively** by `bun install` and `bun run build` at deploy time. Dependencies come from npm, pinned by `bun.lock` but not by Nix hashes. The build needs network access, and its output lives in `\u002Fvar\u002Flib`, not in `\u002Fnix\u002Fstore`.\n- **Site versions aren't part of NixOS generations.** Rolling back the system doesn't roll back the site (the `releases\u002F` folder handles that separately).\n- The **`LD_LIBRARY_PATH` workaround** keeps prebuilt binaries happy. The pure approach builds `sharp` against Nix's `vips` instead.\n\n**What the pure way would look like:** package the site as a Nix derivation, with [bun2nix](https:\u002F\u002Fgithub.com\u002FbaileyluTCD\u002Fbun2nix){target=\"_blank\"} turning `bun.lock` into fixed-output dependency fetches. The build output lands in `\u002Fnix\u002Fstore`, and the service's `ExecStart` points into the store. Deploying a new version means `nix flake update mysite && just deploy`.\n\n**Why I didn't do that (yet):** with a git-based CMS, **every content edit would mean updating a lock file and rebuilding the whole system**, and automating that across two repos means giving CI write access to my infra repo. Nuxt, Bun and sharp are also known to be fiddly to package purely. Because content changes much more often than infrastructure, splitting things this way made sense: **NixOS owns the infrastructure, and the app ships its own releases.**\n\nThe pure version is on my list as a learning project.\n\n## Part 9: My mistakes, collected\n\n| Mistake | How it showed up |\n| --- | --- |\n| `nixosConfiguration` (no s) | Nothing, until a tool looked for `nixosConfigurations` |\n| `user.users` | `The option 'user' does not exist` (caught by `nix eval`) |\n| `{ pkgs, ... };` | Syntax error: modules need `:` |\n| `User = \"mysite\",` | `unexpected ','`: attributes end with `;` |\n| `github:NixIS\u002Fnixpkgs` | Repository not found |\n| `seld` instead of `self` | `called without required argument` |\n| `ciKey = ssh-ed25519 …` (no quotes) | `undefined variable 'ssh-ed25519'` |\n| Forgot `git add` on a new file (happened numerous times) | Nix acts like the file doesn't exist |\n| Password typed at `login:` | Printed in plain text on the console |\n| `nix-instantiate --parse x.nix \u002Fdev\u002Fnull` (missing `>`) | `'\u002Fdev\u002Fnull' is not a regular file`, though the parse itself was fine |\n\nAs you can see, majority are typos. :)\n\nThe NixOS module system has a schema, so `nix eval` catches most of these before anything touches the server. Run it all the time.\n\n## Part 10: What's next\n\nSome stuff that I plan to use and implement for learning purposes:\n\n- **sops-nix**, for secrets that live encrypted in the repo\n- **restic** backups\n- **PostgreSQL** for the next app\n- **comin**, if I ever have more than one server\n- **bun2nix**, for the pure Nix version of the site\n\nIf you're thinking about trying NixOS on Oracle's free tier: do it. Test the serial console first, keep `--kexec` in your back pocket, and run `nix eval` before every deploy.\n\n_The config is at [github.com\u002Fdesignbyfaizi\u002Fnixos-config](https:\u002F\u002Fgithub.com\u002Fdesignbyfaizi\u002Fnixos-config){target=\"_blank\"}._\n\n![A small hand-drawn character in a black hoodie with rose drawstrings sits on the floor, blushing, under handwritten text that says thank you for reading, with a little rose heart](\u002Fuploads\u002Fblog\u002Fnixos-thanks.webp \"See you in the next one\"){width=\"420\" height=\"420\"}\n",{"title":5,"description":4695},"blog\u002Fnixos-on-oracle-cloud",[4704,4705,4706,4707],"nixos","oracle-cloud","self-hosting","nuxt","c6N9iy7ViqnxlaZUL-sMN3LSZKqOCZ0sIOCOgXXn3aQ",[4710,4711],null,{"title":4712,"path":4713,"stem":4714,"children":-1},"Hello World: How I Built This Site with Nuxt","\u002Fblog\u002Fhello-world","blog\u002Fhello-world",{"left":4716,"top":4716,"width":884,"height":884,"rotate":4716,"vFlip":4717,"hFlip":4717,"body":4718},0,false,"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m12 19l-7-7l7-7m7 7H5\"\u002F>",1794232070469]