Kieran's opinionated (and probably slightly dumb) nix config
1# NixOS Service Modules
2
3This directory contains reusable NixOS service modules for deploying applications.
4
5## Architecture
6
7Each service module follows a common pattern for deploying TypeScript/Bun applications:
8
9### Directory Structure
10- **Data Directory**: `/var/lib/<service-name>/`
11 - `app/` - Git repository clone
12 - `data/` - Application data (databases, uploads, etc.)
13
14### Systemd Service Pattern
15
161. **ExecStartPre** (runs as root with `+` prefix):
17 - Creates data directories
18 - Sets ownership to service user
19 - Ensures proper permissions
20
212. **preStart** (runs as service user):
22 - Clones git repository if needed
23 - Pulls latest changes (if `autoUpdate` enabled)
24 - Runs `bun install`
25 - Initializes database if needed
26
273. **ExecStart** (runs as service user):
28 - Starts the application with `bun start`
29
30### Common Options
31
32All service modules support:
33
34```nix
35atelier.services.<service-name> = {
36 enable = true; # Enable the service
37 domain = "app.example.com"; # Domain for Caddy reverse proxy
38 port = 3000; # Port the app listens on
39 dataDir = "/var/lib/<service>"; # Data storage location
40 secretsFile = path; # agenix secrets file
41 repository = "https://..."; # Git repository URL
42 autoUpdate = true; # Git pull on service restart
43};
44```
45
46### Secrets Management
47
48Secrets are managed using [agenix](https://github.com/ryantm/agenix):
49
501. Add secret to `secrets/secrets.nix`:
51 ```nix
52 "service-name.age".publicKeys = [ kierank ];
53 ```
54
552. Create and encrypt the secret:
56 ```bash
57 agenix -e secrets/service-name.age
58 ```
59
603. Add environment variables (one per line):
61 ```
62 DATABASE_URL=postgres://...
63 API_KEY=xxxxx
64 SECRET_TOKEN=yyyyy
65 ```
66
674. Reference in machine config:
68 ```nix
69 age.secrets.service-name = {
70 file = ../../secrets/service-name.age;
71 owner = "service-name";
72 };
73
74 atelier.services.service-name = {
75 secretsFile = config.age.secrets.service-name.path;
76 };
77 ```
78
79### Reverse Proxy (Caddy)
80
81Each service automatically configures a Caddy virtual host with:
82- Cloudflare DNS challenge for TLS
83- Reverse proxy to the application port
84
85## GitHub Actions Deployment
86
87Services can be deployed via GitHub Actions using SSH over Tailscale.
88
89### Prerequisites
90
911. **Tailscale OAuth Client**:
92 - Create at https://login.tailscale.com/admin/settings/oauth
93 - Required scope: `auth_keys` (to authenticate ephemeral nodes)
94 - Add to GitHub repo secrets:
95 - `TS_OAUTH_CLIENT_ID`
96 - `TS_OAUTH_SECRET`
97
982. **SSH Access**:
99 - Add the service user to Tailscale SSH ACLs
100 - Example in `tailscale.com/admin/acls`:
101 ```json
102 "ssh": [
103 {
104 "action": "accept",
105 "src": ["tag:ci"],
106 "dst": ["tag:server"],
107 "users": ["cachet", "hn-alerts", "root"]
108 }
109 ]
110 ```
111
112### Workflow Template
113
114Create `.github/workflows/deploy-<service>.yaml`:
115
116```yaml
117name: Deploy <Service Name>
118
119on:
120 push:
121 branches:
122 - main
123 workflow_dispatch:
124
125jobs:
126 deploy:
127 runs-on: ubuntu-latest
128 steps:
129 - uses: actions/checkout@v3
130
131 - name: Setup Tailscale
132 uses: tailscale/github-action@v3
133 with:
134 oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
135 oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
136 tags: tag:ci
137 use-cache: "true"
138
139 - name: Configure SSH
140 run: |
141 mkdir -p ~/.ssh
142 echo "StrictHostKeyChecking no" >> ~/.ssh/config
143
144 - name: Deploy to server
145 run: |
146 ssh <service-user>@<hostname> << 'EOF'
147 cd /var/lib/<service>/app
148 git fetch --all
149 git reset --hard origin/main
150 bun install
151 sudo /run/current-system/sw/bin/systemctl restart <service>.service
152 EOF
153
154 - name: Wait for service to start
155 run: sleep 10
156
157 - name: Health check
158 run: |
159 HEALTH_URL="https://<domain>/health"
160 MAX_RETRIES=6
161 RETRY_DELAY=5
162
163 for i in $(seq 1 $MAX_RETRIES); do
164 echo "Health check attempt $i/$MAX_RETRIES..."
165
166 RESPONSE=$(curl -s -w "\n%{http_code}" "$HEALTH_URL" || echo "000")
167 HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
168 BODY=$(echo "$RESPONSE" | sed '$d')
169
170 if [ "$HTTP_CODE" = "200" ]; then
171 echo "✅ Service is healthy"
172 echo "$BODY"
173 exit 0
174 fi
175
176 echo "❌ Health check failed with HTTP $HTTP_CODE"
177 echo "$BODY"
178
179 if [ $i -lt $MAX_RETRIES ]; then
180 echo "Retrying in ${RETRY_DELAY}s..."
181 sleep $RETRY_DELAY
182 fi
183 done
184
185 echo "❌ Health check failed after $MAX_RETRIES attempts"
186 exit 1
187```
188
189### Deployment Flow
190
1911. Push to `main` branch triggers workflow
1922. GitHub Actions runner joins Tailscale network
1933. SSH to service user on target server
1944. Git pull latest changes
1955. Install dependencies
1966. Restart systemd service
1977. Verify health check endpoint
198
199## Creating a New Service Module
200
2011. Copy an existing module (e.g., `cachet.nix` or `hn-alerts.nix`)
2022. Update service name, user, and group
2033. Adjust environment variables as needed
2044. Add database initialization if required
2055. Configure secrets in `secrets/secrets.nix`
2066. Import in machine config
2077. Create GitHub Actions workflow (if needed)
208
209## Example Services
210
211- **cachet** - Slack emoji/profile cache
212- **hn-alerts** - Hacker News monitoring and alerts