Compare commits

18 Commits
Author SHA1 Message Date
aykhans 1f876b1402 update README.md 2026-07-13 15:32:00 +04:00
aykhans 2b8b8b6514 fix(cadvisor): set -containerd to Docker's socket so per-container metrics work 2026-07-13 15:17:31 +04:00
aykhans b53c6b3d4a preflight: ensure ~/.docker/config.json is a file (watchtower mount) 2026-07-13 14:33:06 +04:00
aykhans adc45ebc56 fix data ownership for fixed-uid services before start 2026-07-13 14:15:14 +04:00
aykhansandGitHub 7f8bfb81c0 Merge pull request #1 from aykhans/nixos-migration
NixOS migration
2026-07-13 12:21:37 +04:00
aykhans e9ae811fcf gitea: route SSH to container's built-in sshd on port 22 2026-07-13 04:51:51 +04:00
aykhans 3c46b0de47 gitea: expose SSH on port 2222 2026-07-13 04:46:01 +04:00
aykhans 6d9c4e7d39 crowdsec: use sshd.service unit for journalctl ssh acquisition on NixOS 2026-07-13 04:34:51 +04:00
aykhans b1d78905ca grafana: add hetzner storage box dashboard 2026-07-09 18:35:15 +04:00
aykhans 72e34c982c prometheus: add Hetzner storagebox exporter 2026-07-09 18:26:22 +04:00
aykhans 9630364e8e update README.md 2026-07-03 23:24:00 +04:00
aykhans e67b05d6c0 immich: bump versions 2026-07-03 14:36:48 +00:00
aykhans fb9aaf1a94 caddy: drop appsec WAF from sftpgo and immich to fix >10MB upload 403s 2026-07-02 08:26:07 +00:00
aykhans 4f30cb18e4 crowdsec/exporter: POLL_INTERVAL_SECS 30s -> 120s 2026-06-23 13:48:19 +00:00
aykhans 018bf69289 crowdsec: add resource limits 2026-06-16 21:32:07 +04:00
aykhans 1927243369 glance: bump v0.8.4 -> v0.8.5 2026-06-03 11:09:29 +00:00
aykhans 383914d4c5 sftpgo: remove dead keep_alive_interval env var (no such config in SFTPGo) 2026-05-31 20:42:49 +04:00
aykhans 943d136d79 sftpgo: fix env var names (single→double underscore for nested keys) 2026-05-31 20:36:21 +04:00
15 changed files with 2374 additions and 30 deletions
+30 -17
View File
@@ -20,6 +20,8 @@
- 9009-9013/tcp (relay)
- **SFTPGo**
- 2022/tcp (SFTP)
- **Gitea**
- 2222/tcp (SSH)
- **WireGuard Easy**
- 51820/udp (WireGuard)
@@ -58,6 +60,7 @@ Edit the generated `.env` files to fill in the required fields:
- `./stalwart/.env`
- `./caddy/.env`
- `./crowdsec/.env`
- `./prometheus/.env`
- `./caddy/Caddyfile.private`
### 4. Bouncer Keys (CrowdSec)
@@ -88,24 +91,9 @@ Launch all services with the following command:
### 6. Host Firewall Bouncer (CrowdSec, nftables)
The Caddy bouncer protects HTTP services. Stalwart's mail ports (25/465/587/143/993/110/995/4190) bypass Caddy, so install a firewall bouncer on the host. CrowdSec packages live on PackageCloud, not in the default apt repos, so add the repo first:
The Caddy bouncer protects HTTP services. Stalwart's mail ports (25/465/587/143/993/110/995/4190) bypass Caddy, so run a CrowdSec **firewall bouncer** on the host itself (nftables backend).
```sh
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash
sudo apt install crowdsec-firewall-bouncer-nftables
```
Do NOT use `install.crowdsec.net` (that installs the engine too, which we already run in Docker).
Patch the default config (the package writes `api_url: http://127.0.0.1:8080/` but our LAPI is on 18080):
```sh
FW_KEY=$(grep '^CROWDSEC_BOUNCER_KEY_FW=' ./crowdsec/.env | cut -d= -f2)
sudo sed -i "s|^api_url:.*|api_url: http://127.0.0.1:18080/|" /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
sudo sed -i "s|^api_key:.*|api_key: $FW_KEY|" /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
sudo systemctl enable --now crowdsec-firewall-bouncer
sudo systemctl status crowdsec-firewall-bouncer --no-pager
```
Configure it to talk to the engine's LAPI on `127.0.0.1:18080` (published by the crowdsec container) and give it an `api_key` equal to `CROWDSEC_BOUNCER_KEY_FW` from step 4. Do NOT install the CrowdSec engine on the host, it already runs in Docker. For installing and configuring the bouncer itself, see the official docs: https://docs.crowdsec.net/u/bouncers/firewall/ (repo: https://github.com/crowdsecurity/cs-firewall-bouncer).
Verify:
@@ -114,6 +102,31 @@ docker exec crowdsec cscli bouncers list # 'firewall' should appear with a
sudo nft list ruleset | grep crowdsec # kernel-level rules in place
```
## Backups (optional, restic)
This is **optional**, use it only if you want off-site, encrypted backups of your service data. Here it is done with [restic](https://github.com/restic/restic) over SFTP (e.g. a Hetzner Storage Box). restic encrypts client-side, deduplicates and compresses, so no separate `tar`/`gzip` is needed. Run as **root**, since Docker-created service data is mostly root-owned.
Installing restic and scheduling the backup depend on the host and are out of scope here. Replace the placeholders: `USER@HOST:PORT` (SFTP target), `/path/to/restic-repo` (repo path on the server), `YOUR_PASSWORD` (losing it means the backup is unrecoverable), `/path/to/services` (directory to back up). Do not commit real secrets to git.
```sh
export RESTIC_REPOSITORY="sftp://USER@HOST:PORT//path/to/restic-repo" # note the double slash before an absolute path
export RESTIC_PASSWORD="YOUR_PASSWORD"
restic init # once
restic backup /path/to/services --compression max
restic forget --keep-last 3 --prune # keep only the 3 newest snapshots
restic snapshots
```
Restore (from any machine with restic + repo access + password, as root):
```sh
restic snapshots
restic restore latest --target /restore/destination
```
Other useful commands: `restic check` (verify integrity), `restic unlock` (clear a stale lock), `restic stats`.
## Stopping Services
To stop all running services, use:
+2 -2
View File
@@ -128,7 +128,7 @@
max_size 8120MB
}
route {
import security
crowdsec
reverse_proxy http://sftpgo:8080 {
header_up Host {http.request.host}
}
@@ -170,7 +170,7 @@
max_size 1024MB
}
route {
import security
crowdsec
reverse_proxy http://immich_server:2283 {
header_up Host {http.request.host}
}
+2 -1
View File
@@ -1,5 +1,6 @@
source: journalctl
journalctl_filter:
- "_SYSTEMD_UNIT=ssh.service"
# NixOS names the OpenSSH unit "sshd.service"; Debian/Ubuntu use "ssh.service".
- "_SYSTEMD_UNIT=sshd.service"
labels:
type: syslog
+16 -1
View File
@@ -48,6 +48,11 @@ services:
- /run/log/journal:/run/log/journal:ro
- /etc/machine-id:/etc/machine-id:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
deploy:
resources:
limits:
cpus: "0.6"
memory: 768M
logging:
driver: "json-file"
options:
@@ -63,13 +68,18 @@ services:
environment:
CROWDSEC_LAPI_URL: "http://crowdsec:8080"
CROWDSEC_API_KEY: "${CROWDSEC_BOUNCER_KEY_EXPORTER}"
POLL_INTERVAL_SECS: "30"
POLL_INTERVAL_SECS: "300"
LISTEN_PORT: "9100"
GEOIP_CITY_DB: "/geoip/GeoLite2-City.mmdb"
volumes:
- ./data/geoip:/geoip:ro
depends_on:
- crowdsec
deploy:
resources:
limits:
cpus: "0.25"
memory: 128M
logging:
driver: "json-file"
options:
@@ -89,6 +99,11 @@ services:
GEOIPUPDATE_FREQUENCY: "24"
volumes:
- ./data/geoip:/usr/share/GeoIP
deploy:
resources:
limits:
cpus: "0.25"
memory: 64M
logging:
driver: "json-file"
options:
+2
View File
@@ -1,5 +1,7 @@
# URL of the Gitea instance
GITEA_INSTANCE_URL=http://gitea:3000
# Domain advertised in SSH clone URLs (e.g. gitea.aykhans.me)
GITEA_SSH_DOMAIN=
# How to get the token: https://docs.gitea.com/usage/actions/act-runner#obtain-a-registration-token
GITEA_RUNNER_REGISTRATION_TOKEN=
# UID and GID of the user that should run the runner (e.g. USER_UID=1000, USER_GID=1000)
+4
View File
@@ -15,10 +15,14 @@ services:
environment:
- USER_UID=${USER_UID}
- USER_GID=${USER_GID}
- GITEA__server__SSH_DOMAIN=${GITEA_SSH_DOMAIN}
- GITEA__server__SSH_PORT=2222
restart: unless-stopped
networks:
- gitea
- caddy
ports:
- "2222:22"
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
+1 -1
View File
@@ -6,7 +6,7 @@ networks:
services:
glance:
image: glanceapp/glance:v0.8.4
image: glanceapp/glance:v0.8.5
container_name: glance
restart: unless-stopped
networks:
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -9,7 +9,7 @@ networks:
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:v2
image: ghcr.io/immich-app/immich-server:v3
labels:
- "com.centurylinklabs.watchtower.enable=true"
volumes:
@@ -65,7 +65,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
networks:
- immich
healthcheck:
@@ -79,7 +79,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
+25
View File
@@ -65,11 +65,36 @@ generate_env_files() {
cp --update=none ./croc/.env.example ./croc/.env
cp --update=none ./stalwart/.env.example ./stalwart/.env
cp --update=none ./crowdsec/.env.example ./crowdsec/.env
cp --update=none ./prometheus/.env.example ./prometheus/.env
cp --update=none ./caddy/Caddyfile.private.example ./caddy/Caddyfile.private
print_success ".env files generated."
}
# Some containers run as a fixed non-root uid and bind-mount their data from
# this repo. A fresh `git clone` (or a non-root restore) leaves that data owned
# by the cloning user, so those services can't write to it. Docker does not
# chown bind mounts, so reset ownership to each container's uid before starting.
# Add a line here whenever a new service pins a non-root `user:`.
fix_permissions() {
echo "Fixing data ownership for fixed-uid services..."
# Only services that run as a fixed non-root uid AND write to a bind-mounted
# data dir need this. Root containers write fine; images like mysql/gitea
# chown their own data via a root entrypoint; grafana/prometheus here only
# read config or use no writable bind mount, so they are intentionally absent.
sudo chown -R 1001:1001 ./stalwart/data # stalwart: compose user "1001:1001"
sudo chown -R 1000:1000 ./sftpgo/data # sftpgo: image default user 1000
}
start_services() {
fix_permissions
# Ensure ~/.docker/config.json is a FILE before any container mounts it.
# watchtower bind-mounts it; if it is missing, the Docker daemon creates it
# as a root-owned directory, which then breaks every docker CLI call.
mkdir -p ~/.docker
[ -d ~/.docker/config.json ] && sudo rmdir ~/.docker/config.json
[ -f ~/.docker/config.json ] || echo '{}' > ~/.docker/config.json
docker network create caddy 2>/dev/null
docker network create grafana 2>/dev/null
docker network create gitea 2>/dev/null
+4
View File
@@ -0,0 +1,4 @@
############# Hetzner Storage Box exporter #############
# Read-only Hetzner Cloud API token: Cloud Console -> Security -> API Tokens.
# The Storage Box must be managed via the Hetzner Cloud API.
HETZNER_TOKEN=
+5
View File
@@ -26,3 +26,8 @@ scrape_configs:
scrape_interval: 30s
static_configs:
- targets: ["crowdsec_exporter:9100"]
- job_name: storagebox_exporter
scrape_interval: 60s
static_configs:
- targets: ["storagebox_exporter:9509"]
+20
View File
@@ -66,6 +66,26 @@ services:
- -housekeeping_interval=3s
- -storage_duration=2m
- -docker_only
- -containerd=/var/run/docker/containerd/containerd.sock
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "3"
storagebox_exporter:
image: ghcr.io/crstian19/prometheus-storagebox-exporter:0.5
container_name: storagebox_exporter
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
- prometheus
environment:
HETZNER_TOKEN: "${HETZNER_TOKEN}"
LISTEN_ADDRESS: ":9509"
METRICS_PATH: "/metrics"
LOG_LEVEL: "info"
logging:
driver: "json-file"
options:
+2 -2
View File
@@ -1,6 +1,6 @@
# https://docs.sftpgo.com/latest/config-file/ (Common > idle_timeout)
SFTPGO_COMMON_IDLE_TIMEOUT=15
SFTPGO_COMMON__IDLE_TIMEOUT=120
# https://docs.sftpgo.com/latest/config-file/ (Common > upload_mode)
SFTPGO_COMMON_UPLOAD_MODE=0
SFTPGO_COMMON__UPLOAD_MODE=0
# https://docs.sftpgo.com/latest/config-file/ (HTTP server > bindings > ip_proxy_header)
SFTPGO_HTTPD__BINDINGS__0__CLIENT_IP_PROXY_HEADER=X-Real-IP
+2 -3
View File
@@ -14,10 +14,9 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
environment:
- SFTPGO_COMMON_IDLE_TIMEOUT=${SFTPGO_COMMON_IDLE_TIMEOUT}
- SFTPGO_COMMON_UPLOAD_MODE=${SFTPGO_COMMON_UPLOAD_MODE}
- SFTPGO_COMMON__IDLE_TIMEOUT=${SFTPGO_COMMON__IDLE_TIMEOUT}
- SFTPGO_COMMON__UPLOAD_MODE=${SFTPGO_COMMON__UPLOAD_MODE}
- SFTPGO_HTTPD__BINDINGS__0__CLIENT_IP_PROXY_HEADER=${SFTPGO_HTTPD__BINDINGS__0__CLIENT_IP_PROXY_HEADER}
- SFTPGO_SFTPD__KEEP_ALIVE_INTERVAL=30
networks:
- caddy
volumes: