Conversation
Add complete NixOS deployment setup with Podman containers, Traefik reverse proxy, sops-nix secrets management, and deploy-rs integration. Includes GitHub Actions CI/CD pipeline for automated builds and deploys.
Confidence Score: 1/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant GHCR as GHCR Registry
participant Server as NixOS Server
Dev->>GH: git push tag v*
GH->>GHCR: docker build + push :latest
GH->>Server: ssh-keyscan (unverified!)
GH->>Server: deploy-rs via github-ci SSH user
Note over GH,Server: github-ci has wheel (full sudo)
Server->>Server: sops-nix decrypts secrets.yaml
Server->>Server: systemd starts podman-network-letta
Server->>Server: Podman starts gel container
Server->>Server: Podman starts letta-bot container
Note over Server: Traefik routes:
Note over Server: domain/bot → letta-bot:8090
Note over Server: db.domain → gel:5656
Note over Server: tr.domain → Traefik dashboard
Last reviewed commit: "feat: add NixOS decl..." |
| { | ||
| users.users.github-ci = { | ||
| isNormalUser = true; | ||
| extraGroups = [ "wheel" ]; |
There was a problem hiding this comment.
github-ci user has unrestricted root access via wheel
The github-ci user is added to the wheel group, and common.nix sets security.sudo.wheelNeedsPassword = false. This means any compromise of the DEPLOY_SSH_KEY GitHub secret immediately grants an attacker full passwordless root access to the production server.
CLAUDE.md documents this user as "CI deploy user (sudo restart only)", but the implementation provides full unrestricted sudo. To match that intent, the user should be removed from wheel and given a targeted sudo rule limited to restarting the specific services:
{ ... }:
{
users.users.github-ci = {
isNormalUser = true;
# No wheel group - restricted to specific sudo commands only
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA+ed2XXpDNh9Nr5ffKC9KTWKK3MNXVTz4bbbRRbA21j github-deploy"
];
};
security.sudo.extraRules = [
{
users = [ "github-ci" ];
commands = [
{ command = "/run/current-system/sw/bin/systemctl restart podman-letta-bot"; options = [ "NOPASSWD" ]; }
];
}
];
}| WEBHOOK_PATH = cfg.webhookPath; | ||
| GEL_HOST = "gel"; | ||
| GEL_PORT = toString cfg.gel.port; | ||
| GEL_CLIENT_TLS_SECURITY = "insecure"; |
There was a problem hiding this comment.
TLS verification disabled for Gel connection
GEL_CLIENT_TLS_SECURITY = "insecure" completely disables TLS certificate verification between the bot container and the Gel container. Even though both containers run on the same host and the Gel server uses a self-signed certificate (GEL_SERVER_TLS_CERT_MODE = "generate_self_signed"), disabling verification means any process that can intercept the Podman network traffic can silently perform a MITM attack on database queries.
A safer alternative is to use "no_system_trust" (or "strict" with a known CA) instead, which validates the certificate but does not require it to be signed by a system CA:
| GEL_CLIENT_TLS_SECURITY = "insecure"; | |
| GEL_CLIENT_TLS_SECURITY = "no_system_trust"; |
| TEMP_DIR=$(mktemp -d) | ||
|
|
||
| install -d -m755 "$TEMP_DIR/etc/ssh" | ||
|
|
||
| ssh-keygen -t ed25519 -f "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key" -N "" -C "" -q | ||
|
|
||
| chmod 600 "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key" | ||
| chmod 644 "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key.pub" | ||
|
|
||
| PUB_KEY=$(cat "$TEMP_DIR/etc/ssh/ssh_host_ed25519_key.pub") | ||
| AGE_KEY=$(echo "$PUB_KEY" | ssh-to-age) | ||
|
|
||
| echo "$TEMP_DIR" | ||
| echo "- &$HOST $AGE_KEY" |
There was a problem hiding this comment.
Private key left in temp dir with no cleanup
The script generates an ed25519 host key pair in a mktemp -d directory and prints only the path and the derived age public key. The private key (ssh_host_ed25519_key) remains on disk in the temp directory indefinitely — it is never copied to the target host, and there are no cleanup instructions.
If this script is run on a developer machine, the private key stays in /tmp/tmp.XXXXXX/etc/ssh/ until the next reboot (or longer if /tmp is persistent). Anyone with local access could read it and derive the corresponding age private key to decrypt the sops secrets.
Consider adding either:
- A
trap "rm -rf $TEMP_DIR" EXITat the top to guarantee cleanup, plus explicit instructions in the output to copy the key to the target before cleanup. - Or provide instructions in the output itself to move the private key to the target host and remove the temp dir immediately.
Example addition:
trap 'rm -rf "$TEMP_DIR"' EXIT
echo ""
echo "IMPORTANT: Copy the private key to the target before this script exits:"
echo " scp $TEMP_DIR/etc/ssh/ssh_host_ed25519_key root@<host>:/etc/ssh/"|
|
||
| services.letta-bot = { | ||
| enable = true; | ||
| image = "ghcr.io/Wizard1209/letta-client-bot:latest"; |
There was a problem hiding this comment.
Image name case inconsistency with module default
The image reference uses a capital W in Wizard1209:
image = "ghcr.io/Wizard1209/letta-client-bot:latest";The default value in letta-bot.nix uses lowercase:
default = "ghcr.io/wizard1209/letta-client-bot:latest";GHCR normalises repository names to lowercase at push time, so both references resolve to the same image today. However, this inconsistency is confusing and could cause issues with tools that treat image names case-sensitively. Consider normalising to lowercase consistently:
| image = "ghcr.io/Wizard1209/letta-client-bot:latest"; | |
| image = "ghcr.io/wizard1209/letta-client-bot:latest"; |
| ''; | ||
|
|
||
| packages = [ | ||
| ] | ||
| ++ (with pkgs; [ | ||
| git | ||
| nix | ||
| nixfmt | ||
| nixos-anywhere | ||
| sops | ||
| ssh-to-age | ||
| wget | ||
| deploy-rs | ||
| ]) | ||
| ++ (with inputs'; [ | ||
| deploy-rs.packages.default |
There was a problem hiding this comment.
deploy-rs included twice in devShell
Both pkgs.deploy-rs (from nixpkgs) and inputs'.deploy-rs.packages.default (from the flake input) are added to the shell's packages list. This adds the same binary twice, making the shell slightly heavier and potentially confusing (different builds of the same tool in PATH).
Since deploy-rs is pinned in flake.lock, prefer the flake input version exclusively and remove the nixpkgs one:
packages = [
]
++ (with pkgs; [
git
nix
nixfmt
nixos-anywhere
sops
ssh-to-age
wget
])
++ (with inputs'; [
deploy-rs.packages.default
]);
Add complete NixOS deployment setup with Podman containers, Traefik reverse proxy, sops-nix secrets management, and deploy-rs integration. Includes GitHub Actions CI/CD pipeline for automated builds and deploys.
Need change secrets for prod