# atproto-likes — the "most-liked accounts" web UI, run as a docker-compose stack # # ⚠️ COPY. Canonical source: # /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix # Pure evaluation cannot import a path outside the flake tree. Re-copy after editing. # # Host-specific context that is deliberately NOT in the public project repo: # - the domain is fronted by the gateway (143.244.213.108); it forwards everything here # - this vhost lists 0.0.0.0 only so it never shares an nginx address group with # oc.commoninternet.net (opencode, on the tailscale address, no auth of its own) # - /srv/project-orchestrator/projects/notplants-atproto/tools/check-exposure.sh asserts # what this host serves publicly; run it after any nginx change # behind the host's nginx. # # Import this from the host configuration: # imports = [ /srv/project-orchestrator/projects/notplants-atproto/nix/atproto-likes.nix ]; # # What it sets up: # * the Docker daemon # * a systemd unit that builds and runs docker-compose.yml from the project checkout # * an nginx vhost for atproto.commoninternet.net with a Let's Encrypt cert, # proxying to the container on 127.0.0.1:8731 # * ports 80/443 opened in the firewall # # DNS / TLS: point the domain's A record at this host (or at a reverse proxy that # forwards to it). ACME HTTP-01 needs the challenge to reach this nginx, so if a # proxy fronts the domain it must forward /.well-known/acme-challenge/ through. # Until a certificate is issued, NixOS installs a self-signed placeholder so nginx # still starts. # # A proxy in front should preserve the Host header (nginx routes by server_name), # pass Upgrade/Connection through (the loading page is a websocket) and allow a # long read timeout (a cold scan can run for minutes). { config, pkgs, lib, ... }: let projectDir = "/srv/project-orchestrator/projects/notplants-atproto"; domain = "atproto.commoninternet.net"; hostPort = 8731; # must match the ports: mapping in docker-compose.yml compose = "${pkgs.docker-compose}/bin/docker-compose"; in { virtualisation.docker = { enable = true; # Reclaim dangling images from repeated `--build` runs. autoPrune = { enable = true; dates = "weekly"; }; }; # Build + run the compose stack. Type=oneshot with RemainAfterExit: compose # detaches, and the containers' own restart policy keeps them alive. systemd.services.atproto-likes = { description = "atproto-likes — most-liked-accounts web UI (docker compose)"; wantedBy = [ "multi-user.target" ]; after = [ "docker.service" "docker.socket" "network-online.target" ]; requires = [ "docker.service" ]; wants = [ "network-online.target" ]; path = [ pkgs.docker pkgs.docker-compose ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; WorkingDirectory = projectDir; # A cold `--build` pulls python:3.12-slim and installs pip deps. TimeoutStartSec = "1800"; # The container runs unprivileged as uid 10001 (see Dockerfile), but the # page cache is a host bind mount, so its ownership is the host's business. # Without this the app 500s on any actor whose cache files were written by # an earlier root-running container. ExecStartPre = [ "${pkgs.coreutils}/bin/mkdir -p ${projectDir}/cache" "${pkgs.coreutils}/bin/chown -R 10001:10001 ${projectDir}/cache" ]; ExecStart = "${compose} up -d --build --remove-orphans"; ExecStop = "${compose} down"; Restart = "on-failure"; RestartSec = "30s"; }; }; services.nginx = { enable = true; recommendedProxySettings = true; virtualHosts.${domain} = { # addSSL, NOT forceSSL. If a reverse proxy fronts this domain and its # back-end hop is plain HTTP, a forced 301 to https:/// would # resolve straight back to that proxy — a redirect loop. Serving both # schemes lets whatever is in front decide where TLS terminates. addSSL = true; enableACME = true; # Listens on 0.0.0.0 only, and the reject-everything default server below # owns those addresses. nginx groups server blocks by the connection's local # address, so keeping this vhost on the public address alone means it never # shares an address group with anything else the host may serve on another # interface — no other service can be reached by sending this one an # unexpected Host header. locations."/" = { proxyPass = "http://127.0.0.1:${toString hostPort}"; # The loading page streams scan progress over a websocket. proxyWebsockets = true; # A cold scan can run for minutes with the socket open; the default 60s # proxy read timeout would cut the loading page off mid-flower. extraConfig = '' proxy_read_timeout 1800s; proxy_send_timeout 1800s; ''; }; }; # Strict default server for the public addresses. Without an explicit # default_server, nginx promotes the first server block in the group — so any # vhost added later silently becomes what answers unmatched, absent or spoofed # Host headers from the internet. This closes that door once: only names # deliberately served get a response. # # port 80 -> 444 (close the connection, no response at all) # port 443 -> ssl_reject_handshake, so an unknown SNI never even gets TLS virtualHosts."public-default-reject" = { default = true; serverName = null; rejectSSL = true; listen = [ { addr = "0.0.0.0"; port = 80; ssl = false; } { addr = "0.0.0.0"; port = 443; ssl = true; } ]; extraConfig = "return 444;"; }; }; security.acme = { acceptTerms = true; defaults.email = "mfowler.email@protonmail.com"; # ACME contact }; # Public HTTP/HTTPS. networking.firewall.allowedTCPPorts = [ 80 443 ]; }