This guide provides complete, ready-to-use NixOS configurations for each service. Each recipe includes all necessary configuration, secrets, DNS setup, and verification steps.
2. Bluesky PDS
3. Tangled Knot
9. QuickDID
Each recipe follows this format:
Service Name
├── Prerequisites (what you need first)
├── Configuration (copy-pasteable Nix code)
├── Secrets Required (what to generate)
├── DNS Records (what to add)
├── Verification (how to test)
└── Troubleshooting (common issues)
}}}
== Bluesky PDS ==
=== Prerequisites ===
- [ ] Caddy configured with working HTTPS
- [ ] Secrets management set up
- [ ] Domain `pds.yourdomain.com` ready
- [ ] Email SMTP account (for password resets)
=== Configuration ===
Add to `/etc/nixos/configuration.nix`:
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
pdsPort = 2583; # Changed from default 3000 to avoid conflicts
in
{
# Add PDS secrets file
sops.secrets = {
PDSJWTSECRET = {};
PDSADMINPASSWORD = {};
PDSPLCROTATIONKEYK256PRIVATEKEY_HEX = {};
PDSEMAILSMTP_URL = {};
PDSEMAILFROM_ADDRESS = {};
};
sops.templates."pds-env".content = ''
PDSJWTSECRET=${config.sops.placeholder.PDSJWTSECRET}
PDSADMINPASSWORD=${config.sops.placeholder.PDSADMINPASSWORD}
PDSPLCROTATIONKEYK256PRIVATEKEYHEX=${config.sops.placeholder.PDSPLCROTATIONKEYK256PRIVATEKEYHEX}
PDSEMAILSMTPURL=${config.sops.placeholder.PDSEMAILSMTPURL}
PDSEMAILFROMADDRESS=${config.sops.placeholder.PDSEMAILFROMADDRESS}
'';
# Bluesky PDS service
services.bluesky-pds = {
enable = true;
package = pkgs.bluesky-pds;
environmentFiles = [ config.sops.templates."pds-env".path ];
settings = {
PDS_HOSTNAME = "pds.${domainName}";
PDSSERVICEDID = "did:web:pds.${domainName}";
PDS_PORT = pdsPort;
PDSDATADIRECTORY = "/var/lib/pds";
PDSBLOBSTOREDISK_LOCATION = "/var/lib/pds/blocks";
PDSBLOBUPLOAD_LIMIT = "104857600"; # 100MB
PDSDIDPLC_URL = "https://plc.directory";
PDSBSKYAPPVIEWURL = "https://api.bsky.app";
PDSBSKYAPPVIEWDID = "did:web:api.bsky.app";
PDSREPORTSERVICE_URL = "https://mod.bsky.app";
PDS_CRAWLERS = "https://bsky.network,https://api.bsky.app";
PDSINVITEREQUIRED = "true";
PDSRATELIMIT_ENABLED = "true";
LOG_ENABLED = "1";
LOG_LEVEL = "info";
};
};
# Caddy virtual host for PDS (wildcards for user subdomains)
services.caddy.virtualHosts = {
"*.pds.${domainName}, pds.${domainName}" = {
extraConfig = ''
tls {
on_demand
}
reverse_proxy http://127.0.0.1:${toString pdsPort} {
transport http {
read_timeout 0
write_timeout 0
dial_timeout 30s
keepalive 90s
keepaliveidleconns 10
}
}
'';
};
};
}
}}}
Create /etc/nixos/secrets/bluesky-pds.yaml:
PDS_JWT_SECRET: "$(openssl rand -hex 32)"
PDS_ADMIN_PASSWORD: "$(pwgen -s 32 1)"
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: "$(openssl ecparam -name secp256k1 -genkey -noout | openssl ec -outform DER | tail -c 32 | xxd -p -c 64)"
PDS_EMAIL_SMTP_URL: "smtps://your-email@gmail.com:app-password@smtp.gmail.com:465"
PDS_EMAIL_FROM_ADDRESS: "noreply@yourdomain.com"
}}}
*Important:* Replace with actual generated values.
=== DNS Records ===
pds.yourdomain.com A YOURSERVERIP
*.pds.yourdomain.com A YOURSERVERIP
}}}
# 1. Check service is running
systemctl status bluesky-pds
# 2. Check health endpoint
curl https://pds.yourdomain.com/xrpc/_health
# 3. Check DID document
curl https://pds.yourdomain.com/.well-known/did.json
# 4. Check admin API (create invite)
# First, get admin password
sudo cat /run/secrets/PDS_ADMIN_PASSWORD
# Then test
curl -X POST \
-u "admin:YOUR_ADMIN_PASSWORD" \
https://pds.yourdomain.com/xrpc/com.atproto.server.createInviteCode \
-H "Content-Type: application/json" \
-d '{"useCount": 1}'
}}}
=== Troubleshooting ===
*"DID resolution failed"*
- Check DNS: `dig pds.yourdomain.com`
- Verify Caddy is serving `.well-known/did.json`
- Check PDS logs: `journalctl -u bluesky-pds -f`
*"Cannot create account"*
- Verify PLC rotation key is valid hex string
- Check SMTP settings in logs
- Ensure PDS_EMAIL_FROM_ADDRESS matches SMTP account
*"Certificate errors for user subdomains"*
- Verify wildcard DNS record exists: `*.pds.yourdomain.com`
- Check Caddy logs for on-demand TLS failures
- Ensure PDS is responding to `/tls-check` endpoint
---
== Tangled Knot ==
=== Prerequisites ===
- [ ] Caddy configured
- [ ] ATProto DID (from your Bluesky account or PDS)
=== Configuration ===
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
ownerDid = "did:plc:YOURDIDHERE"; # Get from Bluesky
in
{
services.tangled-knot = {
enable = true;
server = {
hostname = "knot.${domainName}";
owner = ownerDid;
};
openFirewall = true;
};
services.caddy.virtualHosts = {
"knot.${domainName}" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
ratelimit @apilimit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:5555
}
'';
};
};
}
}}}
knot.yourdomain.com A YOUR_SERVER_IP
}}}
=== Verification ===
# Check service
systemctl status tangled-knot
# Clone test repo (from your local machine)
git clone https://knot.yourdomain.com/your-did/test-repo
# Check git protocol works
curl -I https://knot.yourdomain.com
}}}
---
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
ownerDid = "did:plc:YOUR_DID_HERE";
in
{
services.tangled-spindle = {
enable = true;
server = {
hostname = "spindle.${domainName}";
owner = ownerDid;
listenAddr = "0.0.0.0:6555";
jetstreamEndpoint = "wss://jetstream1.us-west.bsky.network/subscribe";
maxJobCount = 4;
queueSize = 100;
secrets.provider = "sqlite";
};
endpoints = {
appview = "https://tangled.org";
knot = "https://knot.${domainName}";
jetstream = "wss://jetstream.tangled.sh";
nixery = "https://nixery.tangled.sh";
atproto = "https://bsky.social";
plc = "https://plc.directory";
};
pipelines.workflowTimeout = "10m";
openFirewall = true;
};
services.caddy.virtualHosts = {
"spindle.${domainName}, sp.${domainName}" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
rate_limit @api_limit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:6555
}
'';
};
};
}
}}}
=== DNS Records ===
spindle.yourdomain.com A YOURSERVERIP
sp.yourdomain.com A YOURSERVERIP
}}}
# Check service
systemctl status tangled-spindle
# Check job queue
journalctl -u tangled-spindle -f | grep -i "job\|queue"
}}}
---
== Microcosm Spacedust ==
=== Prerequisites ===
- [ ] Basic NixOS setup
- [ ] Jetstream URL
=== Configuration ===
{ config, pkgs, ... }:
let
jetstreamUrl = "wss://jetstream1.us-west.bsky.network/subscribe";
in
{
services.microcosm-spacedust = {
enable = true;
jetstream = jetstreamUrl;
package = pkgs.microcosm-spacedust;
};
services.caddy.virtualHosts = {
"spacedust.yourdomain.com, sd.yourdomain.com" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
ratelimit @apilimit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:9998
}
'';
};
};
}
}}}
spacedust.yourdomain.com A YOUR_SERVER_IP
sd.yourdomain.com A YOUR_SERVER_IP
}}}
---
== Microcosm Slingshot ==
=== Configuration ===
{ config, pkgs, ... }:
let
jetstreamUrl = "wss://jetstream1.us-west.bsky.network/subscribe";
in
{
services.microcosm-slingshot = {
enable = true;
jetstream = jetstreamUrl;
package = pkgs.microcosm-slingshot;
};
services.caddy.virtualHosts = {
"slingshot.yourdomain.com" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
ratelimit @apilimit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:3000
}
'';
};
"ss.yourdomain.com" = {
extraConfig = ''
redir https://slingshot.yourdomain.com{uri} permanent
'';
};
};
}
}}}
slingshot.yourdomain.com A YOUR_SERVER_IP
ss.yourdomain.com A YOUR_SERVER_IP
}}}
---
== Lycan Feed Generator ==
=== Prerequisites ===
- [ ] PostgreSQL enabled
- [ ] Jetstream access
=== Configuration ===
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
in
{
# PostgreSQL for Lycan
services.postgresql = {
enable = true;
ensureDatabases = [ "lycan" ];
ensureUsers = [{
name = "lycan";
ensureDBOwnership = true;
}];
};
# Lycan service
services.mackuba-lycan = {
enable = true;
hostname = "lycan.${domainName}";
port = 3100;
allowedHosts = [ "lycan.${domainName}" "ly.${domainName}" ];
database.createLocally = true;
relay.jetstreamHost = "jetstream1.us-east.bsky.network";
firehose.userAgent = "Lycan (@${domainName})";
};
services.caddy.virtualHosts = {
"lycan.${domainName}, ly.${domainName}" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
ratelimit @apilimit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:3100
}
'';
};
};
}
}}}
lycan.yourdomain.com A YOUR_SERVER_IP
ly.yourdomain.com A YOUR_SERVER_IP
}}}
=== Verification ===
# Check PostgreSQL
sudo -u postgres psql -c "\l" | grep lycan
# Check Lycan
systemctl status lycan
# Check feed endpoint
curl https://lycan.yourdomain.com/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:xxx/app.bsky.feed.generator/feed-name
}}}
---
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
in
{
# Prometheus
services.prometheus = {
enable = true;
port = 9090;
scrapeConfigs = [
{
job_name = "prometheus";
static_configs = [{
targets = [ "localhost:9090" ];
labels.instance = "prometheus";
}];
}
{
job_name = "node-exporter";
static_configs = [{
targets = [ "localhost:9100" ];
labels.instance = "snek";
}];
}
];
exporters.node = {
enable = true;
port = 9100;
enabledCollectors = [ "systemd" "processes" ];
};
};
# Grafana
services.grafana = {
enable = true;
settings = {
server = {
http_addr = "127.0.0.1";
http_port = 3001;
domain = "grafana.${domainName}";
root_url = "https://grafana.${domainName}";
};
"auth.anonymous" = {
enabled = true;
org_role = "Viewer";
};
};
provision = {
enable = true;
datasources.settings.datasources = [{
name = "Prometheus";
type = "prometheus";
access = "proxy";
url = "http://localhost:9090";
isDefault = true;
}];
};
};
# Caddy virtual hosts
services.caddy.virtualHosts = {
"grafana.${domainName}" = {
extraConfig = ''
reverse_proxy http://127.0.0.1:3001
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
}
'';
};
};
# Dashboard files
environment.etc."grafana-dashboards/system-health.json".source = ./grafana-dashboards/system-health.json;
}
}}}
=== DNS Records ===
grafana.yourdomain.com A YOURSERVERIP
}}}
# Check Prometheus
curl http://localhost:9090/api/v1/status/targets
# Check Grafana
curl -I https://grafana.yourdomain.com
}}}
---
== QuickDID ==
=== Configuration ===
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
in
{
services.smokesignal-events-quickdid = {
enable = true;
package = pkgs.quickdid;
settings = {
httpExternal = "quickdid.${domainName}";
};
};
services.caddy.virtualHosts = {
"quickdid.${domainName}, qd.${domainName}" = {
extraConfig = ''
route {
@api_limit {
not remote_ip 127.0.0.1
}
ratelimit @apilimit {
rate 10r/s
burst 20
}
reverse_proxy http://127.0.0.1:8080
}
'';
};
};
}
}}}
quickdid.yourdomain.com A YOUR_SERVER_IP
qd.yourdomain.com A YOUR_SERVER_IP
}}}
---
== Adding Multiple Services ==
When adding multiple services, organize your configuration:
{ config, pkgs, ... }:
let
domainName = "yourdomain.com";
ownerDid = "did:plc:YOUR_DID";
jetstreamUrl = "wss://jetstream1.us-west.bsky.network/subscribe";
in
{
# ============================================
# SECRETS
# ============================================
sops.secrets = {
PDSJWTSECRET = {};
PDSADMINPASSWORD = {};
# ... other secrets ...
};
sops.templates."pds-env".content = ''
# ... template content ...
'';
# ============================================
# SERVICES
# ============================================
# Bluesky PDS
services.bluesky-pds = {
# ... PDS config ...
};
# Tangled Services
services.tangled-knot = {
# ... Knot config ...
};
services.tangled-spindle = {
# ... Spindle config ...
};
# Microcosm Services
services.microcosm-spacedust = {
# ... Spacedust config ...
};
services.microcosm-slingshot = {
# ... Slingshot config ...
};
# Lycan
services.postgresql = {
# ... PostgreSQL config ...
};
services.mackuba-lycan = {
# ... Lycan config ...
};
# Monitoring
services.prometheus = {
# ... Prometheus config ...
};
services.grafana = {
# ... Grafana config ...
};
# ============================================
# CADDY VIRTUAL HOSTS
# ============================================
services.caddy.virtualHosts = {
# PDS (wildcards)
"*.pds.${domainName}, pds.${domainName}" = {
# ... PDS config ...
};
# Tangled
"knot.${domainName}" = {
# ... Knot config ...
};
"spindle.${domainName}" = {
# ... Spindle config ...
};
# Microcosm
"spacedust.${domainName}" = {
# ... Spacedust config ...
};
"slingshot.${domainName}" = {
# ... Slingshot config ...
};
# Lycan
"lycan.${domainName}" = {
# ... Lycan config ...
};
# Monitoring
"grafana.${domainName}" = {
# ... Grafana config ...
};
};
}
}}}
When adding multiple services, do it in this order:
1. *Base system* (SSH, users, flakes)
2. *Caddy* (for HTTPS)
3. *Secrets management* (sops-nix)
4. *QuickDID* (simplest service to test setup)
5. *Tangled Knot* (if using Tangled)
6. *Tangled Spindle* (depends on Knot)
7. *Microcosm services* (Spacedust, Slingshot)
8. *PostgreSQL + Lycan* (needs database)
9. *Bluesky PDS* (most complex, needs all infrastructure)
10. *Monitoring* (Prometheus + Grafana)
*Port Conflicts:*
# Check for port conflicts
ss -tlnp | grep -E "(2583|3000|5555|6555|8080|9998|3100)"
}}}
**Service Dependencies:**
- Lycan needs PostgreSQL first
- Spindle needs Knot
- Some services need secrets configured
**Resource Limits:**
- Monitor RAM usage: `free -h`
- Check if services are OOM killed: `dmesg | grep -i "killed process"`
== References ==
- [[https://search.nixos.org/options|NixOS Service Options]]
- [[https://github.com/atproto-nix/nur|ATProto NUR Packages]]
- [[https://caddyserver.com/docs/caddyfile/directives/reverse_proxy|Caddy Reverse Proxy]]