Tangled knot on NixOS on Proxmox VE
Self-hosted [Tangled](https://tangled.org) **knot** — a git server on the AT Protocol (the network behind Bluesky) — running on NixOS in an unprivileged Proxmox LXC container. Public identity `did:web:knot.example.com`, TLS certificates via ACME, home IP hidden behind a Cloudflare proxy.
## Architecture
```mermaid
flowchart TB
Browser["Browser / tangled.org"]
Relays["atproto relays"]
Admin["You, on the LAN"]
CF["Cloudflare
proxied DNS · TLS edge · hides home IP"]
Out["Let's Encrypt · relays · DNS"]
subgraph LXC["Proxmox LXC (NixOS)"]
NGINX["nginx
80 / 443"]
SSHD["sshd
22"]
KNOT["knot-server
5555 · 2222
repos · identity · git"]
end
Browser ==>|HTTPS + WebSockets| CF
CF ==>|TCP 80 / 443| NGINX
NGINX ==>|loopback| KNOT
Relays -.->|event stream| CF
Admin -.->|SSH| SSHD
Admin -.->|git push| KNOT
KNOT -.->|announcements · certs · DNS lookups| Out
```
| Port | Service | Who can reach it |
|---|---|---|
| `22` | sshd (server admin) | LAN |
| `2222` | knot git-ssh (git pushes) | LAN / tailnet |
| `80`, `443` | nginx (web, TLS) | public (via Cloudflare) |
| `5555` | knot HTTP | loopback only (behind nginx) |
| UDP `443` | QUIC/HTTP-3 | browser ⇄ Cloudflare only; never reaches the server |
| UDP `53` | DNS | outbound |
## Setup
- Get latest NixOS build for the minimal Proxmox LxC by following the[Proxmox LXC wiki page](https://nixos.wiki/wiki/Proxmox_Linux_Container))
- It's so minimal it won't have ethernet or wifi set so you won't be able to run `nix update-channels`
- when you first get in you'll need to run `source /etc/set-environment` to be able to use the shell
- Set CPU to 2 cores, 8 GB RAM and 20 GB disk. Knot is written in Rust and has no pre-built binary cache, so ~1,450 packages compile from source. Both values can be resized live in Proxmox; shrink back to 2 GB afterward.
- A public hostname in a DNS zone you control
- A Cloudflare API token scoped to `Zone.DNS:Edit` for certificate issuance
- An atproto account: `curl -s 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=YOUR.handle'`
## Secrets
```bash
mkdir -p /etc/knot && chmod 700 /etc/knot
echo "KNOT_MASTER_KEY=$(head -c 32 /dev/urandom | base64)" > /etc/knot/knot.env
chmod 400 /etc/knot/knot.env
```
- **Losing this key destroys the knot's identity.** On first start the knot generates a signing key and locks it with the master key; the `did:web` document is derived from it. Back up `/etc/knot/knot.env` together with `/var/lib/knot`.
- Never store these files in `/etc/nixos/` — that directory is copied into the world-readable nix store on every build. Keep them root-readable only (`chmod 400`); systemd reads them as root before dropping privileges.
## Configuration
Three files in `/etc/nixos/`.
### flake.nix
```nix
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
tangled.url = "git+https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; # Tangled's NixOS module
};
outputs = { self, nixpkgs, tangled, ... }: {
nixosConfigurations.knot = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
tangled.nixosModules.knot-rs
./configuration.nix
./knot.nix ];
};
};
}
```
### configuration.nix — the host (container, network, TLS, proxy)
```nix
{ modulesPath, lib, pkgs, ... }:
{
imports = [ (modulesPath + "/virtualisation/proxmox-lxc.nix") ]; # container settings
nix.settings.sandbox = false; # required inside an unprivileged LXC
system.stateVersion = "26.05"; # set once, never change
networking = {
hostName = "knot"; # note the capital N in hostName
useDHCP = false;
interfaces.eth0.ipv4.addresses = [{ address = "192.168.1.10"; prefixLength = 24; }];
defaultGateway = { address = "192.168.1.1"; interface = "eth0"; }; # the { address; interface; } form is required
nameservers = [ "1.1.1.1" ];
firewall.allowedTCPPorts = [ 80 443 ]; # replaced by the lockdown section below
};
services.openssh = { enable = true; settings.PasswordAuthentication = false; };
users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA... your-public-key" ];
security.acme = {
acceptTerms = true;
defaults.email = "admin@example.com";
certs."example.com" = {
domain = "example.com";
extraDomainNames = [ "knot.example.com" ]; # one certificate covers both names
group = "nginx";
dnsProvider = "cloudflare"; # DNS-01: proves domain control via DNS, no open port needed
dnsResolver = "1.1.1.1:53";
environmentFile = "/var/lib/acme/cloudflare.env"; # contains CLOUDFLARE_DNS_API_TOKEN=…
};
};
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedProxySettings = true; # adds X-Forwarded-For (knot uses it for rate limiting)
virtualHosts."knot.example.com" = {
forceSSL = true;
useACMEHost = "example.com";
locations."/" = {
proxyPass = "http://127.0.0.1:5555";
proxyWebsockets = true; # required: the event stream is a WebSocket
};
locations."/events" = {
proxyPass = "http://127.0.0.1:5555";
proxyWebsockets = true;
extraConfig = "proxy_read_timeout 86400s;";
};
};
};
}
```
### knot.nix
```nix
{ config, pkgs, ... }:
{
services.tangled.knot-rs = {
enable = true;
environmentFile = "/etc/knot/knot.env";
settings = {
server = {
hostname = "knot.example.com"; # becomes the did:web identity; changing it changes identity
admins = [ "did:plc:YOUR_ADMIN_DID" ]; # first entry is the advertised owner
};
atproto.plc_directory = "https://plc.directory"; # required
contact.email = "admin@example.com"; # required
lfs.store_path = "/var/lib/knot/lfs"; # required
xrpc = {
trusted_proxy_header = "x-forwarded-for";
trusted_proxies = [ "127.0.0.1" "::1" ];
};
};
};
}
```
## Create a repo
:warn: Everything on a knot is publicly browsable. There is no private repo option.
1. **Create** at `tangled.org/repo/new` — Owner: your identity, Host: your knot, and tick *Use permalink*.
2. **Register an SSH key** in your tangled.org settings, then verify access: `ssh -T git@ -p 2222` prints `Welcome to this knot!`. That banner is normal output, not an error.
3. **Push** using the *permalink* URL. The knot gives each repository its own DID (returned by the create call as `"did"`):
```bash
git remote add knot "ssh://git@192.168.1.10:2222/did:plc:REPO_DID"
git push knot main
```
- Never add a `.git` suffix to this URL — it will 404.
- Both ends must use the same git object format. Knot-created repositories default to **SHA-256**; if your local repository is SHA-1 you get `fatal: the receiving end does not support this repository's hash algorithm`. Create local repositories with `git init --object-format=sha256`, or set `git config --global init.defaultObjectFormat sha256`.
- The `git` in `git@…` is a protocol convention (like GitHub's), not a user account on the server.
4. **A new repository does not appear on tangled.org until it receives its first push.** Relays only carry live events; content pushed before they start listening is never indexed. If a repository page stays 404, push something and wait a few minutes.
5. **Your handle must resolve to your DID**, or logins fail with `AuthMissing`. Either add a DNS TXT record `_atproto.example.com = did=did:plc:YOUR_ADMIN_DID`, or serve the DID at `https://example.com/.well-known/atproto-did`.
## Gotchas
| Symptom | Cause | Fix |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Assertions about root filesystem / GRUB | NixOS doesn't know it's a container | `boot.isContainer = true;` (the proxmox-lxc profile does this) |
| `…option was accessed but has no value defined` | Required module options, revealed one per build | `grep -n -B1 -A8 mkOption …/nix/modules/knot-rs.nix` |
| Empty `PATH` after `pct enter` | That command skips the login step, so the shell's environment is never set up | Use SSH, or `pct exec -- bash -l` |
| eth0 shows `unmanaged`, no IP | The kernel creates network interfaces powered off and unconfigured; a policy daemon must claim them | Declare networking in NixOS; until the first build, configure manually with `ip addr add` |
| A config file's contents seem ignored | It's not in the flake's `modules` list | Import it; also check options aren't defined twice with different values |
| SSH lockout; knot git-ssh dead | System sshd was moved to 2222 and restricted to a `git` user | Keep sshd on 22 for admin; knot owns 2222 |
| `defaultGateway.interface is not optional` | systemd-networkd requires routes to name an interface | `defaultGateway = { address = …; interface = "eth0"; };` |
| `the receiving end does not support this repository's hash algorithm` | SHA-1 local repository vs SHA-256 knot repository | `git init --object-format=sha256` |
| Repo created but tangled.org shows 404 | No live events have been emitted yet | Push content |
| Cloudflare **Error 1016** | A leftover CNAME record on the name | Delete it; create the A record |
| SSH `Permission denied (publickey)` | A key generated *on* the server was put in `authorizedKeys` | It must hold the **client's** public key (on the client: `ssh-add -L`) |
## Maintenance
- **Updates**: run `nix flake update && nixos-rebuild switch` on a schedule. (`system.autoUpgrade` alone never updates the pinned inputs.) Note that updating nixpkgs triggers a full Rust rebuild.
- **Backups**: `/var/lib/knot` and `/etc/knot/knot.env` together — both are required to keep the identity.
- **If a build runs out of memory** (swap full, console unresponsive), fix it from the Proxmox host: `pct set --memory 8192 --swap 4096` and `pct resize rootfs +10G` (both live, no reboot). To abort a stuck build: `pct exec -- systemctl restart nix-daemon`.
- **Debugging**: `nginx -t -c ` validates the nginx config; `nginx -T -c ` prints the full generated config; `journalctl -u knot-rs -f` tails the knot; `curl -s "https://…/xrpc/org.tangled.knot.describeKnot"` shows what the knot reports about itself.i