Files
cc-ci-orchestrator/nix/atproto-likes.nix
T
autonomic-botandClaude Opus 5 340e0d62a4 nix: sync atproto-likes module (non-root container, cache chown)
Upstream module dropped host-specific comments for its public repo, so the
context that matters here — gateway fronting, why this vhost stays off the
tailscale address group where opencode lives, and the exposure check script —
moves into the copy's header where it belongs.

Functional changes from upstream: the container now runs as uid 10001 and the
unit chowns the bind-mounted cache before start (without it, cache files left
by the earlier root container are unreadable and every cached actor 500s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmEK2voMnBa23495aLk1Ce
2026-08-01 19:32:48 +00:00

136 lines
5.9 KiB
Nix

# 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://<domain>/ 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 ];
}