Self-hosted systems for private networking, Bitcoin and Lightning, observability, and local AI workflows.
Bastion is a deliberate Docker environment for the services that matter:
- A guarded network edge with private DNS and WireGuard.
- Bitcoin, Core Lightning, Tor, TEOS, swaps, automation, and backups.
- Monitoring, administration, and a single operations Hub.
- An AI control plane built around Claude Code Router and CodeDeck+.
Lightning nodes handle real funds. Software is provided as-is. Use only at your own risk. See CLN security docs.
Before you run this with real funds, read docs/disaster-recovery.md (what to back up, how to restore, how to move hosts) and set up the host firewall (docs/firewall.md).
- Docker >= 24.0 & Docker Compose >= 2.0
- Linux (Debian/Ubuntu)
- 16GB+ RAM, 4+ CPU cores, 50GB+ storage
chmod +x bastion
./bastion upWhat happens:
- Generates
bastion.conf(auto-prompted for Wireguard URL, port, CLN alias) - Creates
.envsymlinks βbastion.conf(one source of truth) - Deploys: network β bitcoin β monitor β web β AI
For a subset, name the stacks β stack-network is added automatically and always
comes up first:
./bastion up stack-ai # brings up stack-network too
./bastion up network bitcoin| Stack | Services | IPs |
|---|---|---|
| network | unbound DNS, WireGuard VPN, Pi-hole, Tor | 10.10.0.2-3 + transit |
| bitcoin | bitcoind, lightningd, RTL (TEOS opt-in) | 10.20.0.2-5 + transit |
| monitor | prometheus, grafana, portainer, node-exporter | 10.30.0.2-5 |
| web | Bastion operations hub | 10.40.0.2 |
| ai | Claude Code Router, CodeDeck+ bridge | 10.50.0.2-3 + transit |
The private stack networks use 10.10.0.0/24 through 10.50.0.0/24.
Cross-stack services use the restricted bastion-transit network at
10.254.0.0/24. Pinned there: Tor 10.254.0.2, Core Lightning 10.254.0.10,
TEOS 10.254.0.11 β the last two publish their onion services through Tor and
must advertise a transit address the tor container can route to.
Run ./bastion with no arguments on a terminal for the interactive dashboard
(stack picker, live status, config editor, log viewer). For scripts and cron,
use the subcommands:
./bastion up [stack ...] # Start stacks in order (default: all)
./bastion stop [stack | container]# Stop a stack set, or one container
./bastion down [stack ...] # Remove containers (reverse order)
./bastion build [stack ...] # Build images without starting
./bastion logs [stack | container]# Tail logs
./bastion restart <container> # Restart one container
./bastion start <container> # Start one stopped container
./bastion exec <container> -- ... # Run a command in a container
./bastion shell <container> # Shell into a container
./bastion ps # Every container: state + health + stack
./bastion status # docker ps table
./bastion versions # Image pin vs. running
./bastion audit # Check node profitability
./bastion tui # Force the dashboarddocker compose never needs to be run by hand: the per-container verbs above
(and the TUI's Containers view) cover restart / stop / start / logs / shell.
A stack name may be given with or without the stack- prefix
(./bastion up web ai). On up, --recreate-networks drops a stale
bastion-network left from the old flat-network layout before starting.
--with-watchtower (or BASTION_PROFILES=watchtower) also starts teosd -
your own watchtower - which is otherwise off. ./bastion versions shows the
image pin vs. what is running.
On a fresh install ./bastion up seeds data/rtl/RTL-Config.json and mints an
RTL access rune from CLN; both are created only if absent, so an existing
install is untouched. See stack-bitcoin/README.md.
stack-network is the foundation - it owns the bastion-transit network and
runs Tor, which the Bitcoin and AI stacks attach to. ./bastion up always
brings it up first and adds it automatically when it is left out of the list.
For the same reason it will not stop or down stack-network while another
stack still has containers running; bring those down first, or pass --force.
Any non-interactive invocation (a pipe, cron, services/bastion-daemon.sh)
runs the plain path; the dashboard only opens on a real terminal.
| Service | Port | Location | Version |
|---|---|---|---|
| Hub (Bastion operations hub) | 80 | http://bastion.node | nginx 1.31.5-alpine |
| RTL (Lightning UI) | 3000 | http://bastion.node:3000 | v0.15.8 |
| Grafana | 4001 | http://bastion.node:4001 | 13.2.1 |
| Portainer | 4000 | https://bastion.node:4000 | 2.45.0 |
| Prometheus | 9090 | http://bastion.node:9090 | v3.14.0 |
| Pi-hole | 8081 | http://bastion.node:8081/admin | 2026.07.2 |
| CLN REST API | 3001 | http://bastion.node:3001 | (CLN native) |
| CCR management UI | 3458 | http://bastion.node:3458 | CCR fork commit ec9fc53 |
| Wireguard VPN | 51820/udp | External (WAN) | 1.0.20260223-r0-ls121 |
Image versions are pinned by digest; ./bastion versions shows the pin and what
is actually running. Bitcoin Core (v26.0) and the built images
(lightningd-custom, teosd, tor-custom, bastion-claude-code-router) are
not on this list β they carry no published UI.
Every service above is published on 0.0.0.0 by design, so you can reach it
three ways:
- over WireGuard β the recommended path from outside the LAN;
- from
localhoston the host itself; - from the trusted LAN β e.g. a desktop on the same network.
Pi-hole holds a local-DNS record (bastion.node β <host LAN IP>) so the Hub and
every panel work by name from all three. The Hub itself links panels using
whatever hostname you are browsing with, so you never need to remember a port.
Because the ports are open on every interface, the host firewall is the access
control β this is not optional in production. docs/firewall.md
gives a ready-to-use nftables ruleset and a ufw recipe that allow these ports
from the WireGuard subnet and the LAN and drop them everywhere else, in
particular from any WAN interface / router port-forward. Only 51820/udp
(WireGuard) is meant to face the internet. Portainer (port 4000) mounts the
Docker socket β firewall it the most tightly of all.
Defaults (change immediately):
- Grafana:
admin:admin - Pi-hole:
admin:${PIHOLE_PASSWORD} - Bitcoin RPC:
bitcoind.user:bitcoind.pass - CLN REST API: Access via
http://localhost:3001
./bastion creates and maintains an organized bastion.conf - the single file
you edit:
- Interactive prompts: Wireguard URL/port, CLN node alias
- Auto-generated: TIMEZONE, PIHOLE_PASSWORD, USER_ID, GROUP_ID, CCR web token
- AI settings: CodeDeck relay, Tor proxy, Git, Claude, and GitHub variables
- Idempotent: managed variables are rewritten without duplicates on repeated runs
- Symlinks: each stack references
../bastion.confvia.envon Linux - Secrets: the four secret values (
PIHOLE_PASSWORD,CCR_WEB_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,GITHUB_TOKEN) are also projected intosecrets/(git-ignored,600) and delivered to the one service that needs each as a file under/run/secrets/β¦, not a plaintext env var - so they never appear indocker inspect.bastion.confstays the source of truth;secrets/is regenerated from it and rewritten only when a value changes.
On Windows, use a real .env file in each stack directory if symlinks are not
enabled. Keep credentials out of Git in every environment.
stack-bitcoin/docker-compose.yml # Edit RPC user/pass, pruning settings
stack-bitcoin/config/cln_config # CLN configuration (alias, plugins, proxy)
stack-network/docker-compose.yml # Network, WireGuard, and Tor config
stack-web/html/index.html # Hub landing page (edit to add/remove panels)
bastion.conf # GENERATED - in .gitignore
stack-*/.env # SYMLINKS - in .gitignore
stack-*/data/ # Volumes - in .gitignore
stack-ai/docker-compose.yml # CCR + CodeDeck+ integration
stack-ai/Dockerfile.ccr # CCR pinned commit + Claude Code
The AI stack builds Claude Code Router from a pinned commit of the project's
CCR fork (CCR_REF / CCR_REPOSITORY in stack-ai/Dockerfile.ccr; bump with
gh api repos/deymosh/claude-code-router/commits/<branch> --jq .sha), including
its Docker worker fix, and bakes in an OAuth token refresher so the login stays
valid without manual re-auth. CCR includes Claude Code and runs unprivileged
(cap_drop: ALL, no-new-privileges): its entrypoint wrapper starts as root only
to align file ownership to USER_ID/GROUP_ID, then gosu-drops to that user.
CodeDeck+ uses its published bridge image (ghcr.io/deymosh/codedeck-plus-bridge),
runs as its own non-root user, and routes Claude Code requests through CCR at
http://ccr:8080.
AI state is persisted under stack-ai/data/. CCR authenticates with an
interactive claude login stored under stack-ai/data/ccr/.claude/; CodeDeck's
Claude OAuth token is a separate credential used by the bridge.
Before starting Bastion, add these values to the generated bastion.conf:
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-...
GITHUB_TOKEN= # optional
CODEDECK_RELAYS=wss://your-trusted-relay.exampleCodeDeck relay connections use Bastion's existing Tor service at
socks5h://tor:9050 by default. Pair the Android app by reading the bridge logs:
docker logs codedeck-bridge-rpcuser=bitcoind.user # RPC user (change if desired)
-rpcpassword=bitcoind.pass # RPC password (change if desired)
-prune=20000 # ~20GB block storage (adjust as needed)
-txindex=0 # Disabled (not needed for CLN)
-rpcallowip=10.20.0.0/24 # Allow RPC from the Bitcoin stack network
-rpcbind=0.0.0.0 # Listen on all interfaces (container network)Note: RPC credentials must match those in stack-bitcoin/config/cln_config and RTL config.
Built into the lightningd-custom image; cln_config enables them by default
(except backup, which is installed but left commented out):
| Plugin | Commit | Purpose |
|---|---|---|
| clboss | 95d195f8 | Channel autopilot & rebalancing |
| watchtower-client | be344ecc | TEOS breach watching |
| peerswap | 23b32d3a | Submarine swap rebalancing |
| backup | cb3adab | Replication to USB/external |
| trustedcoin | v0.8.6 | Chain backend in place of bcli: bitcoind when reachable, public block explorers over Tor as fallback |
| darknet | Local | Prefer .onion addresses for peers |
CLN Configuration:
# Autopilot settings (clboss)
--clboss-min-channel=1000000 # Minimum channel: 1M sats
--clboss-max-rebalance-fee-ppm=250 # Max rebalancing fee
--clboss-auto-close=false # Don't auto-close channels
# Autoclean settings (remove failed payments & invoices)
autoclean-failedpays-age=604800 # Remove failed pays after 7 days
autoclean-failedforwards-age=604800 # Remove failed forwards after 7 days
autoclean-expiredinvoices-age=2592000 # Remove expired invoices after 30 days
# Wallet & backup
wallet=sqlite3:///root/.lightning/bitcoin/lightningd.sqlite3:/backup_usb/lightningd.sqlite3
# Network settings
proxy=10.254.0.2:9050 # Tor SOCKS proxy (tor on bastion-transit)
addr=statictor:10.254.0.2:9051 # Tor control port -> static onion
always-use-proxy=true # Route all traffic through Tor
bind-addr=10.254.0.10:9735 # CLN's own bastion-transit address; this is
# the hidden-service forward target Tor dials,
# so it must NOT be 0.0.0.0 (Tor can't reach it)Important Plugins:
trustedcoin- the chain backend in place of the built-inbcli. It usesbitcoindthrough thebitcoin-rpc*lines incln_configwhen that node is reachable and has the block, and only falls back to public block explorers (over Tor) otherwise. Markedimportant-plugin.watchtower-client- Critical for channel security (marked asimportant-plugin)bcli- the built-in backend, disabled (disable-plugin=bcli) sotrustedcoincan take over.
TEOS (teosd) is opt-in and off by default β start it with
./bastion up --with-watchtower (it carries the watchtower compose profile).
Running a watchtower on the same machine as your node is a deliberate choice: it
is meant for offering the service to other nodes, not for watching your own
(that is the always-on watchtower-client plugin, pointed at an external tower).
stack-bitcoin/config/teos.toml is a template. On ./bastion up --with-watchtower
it is copied to stack-bitcoin/data/teos/teos.toml only if that file does not
exist yet β an established install is never touched. Without it teosd would
fall back to rust-teos's compiled-in defaults (api_bind 127.0.0.1, Tor off) and
never be reachable at its pinned transit address.
RTL connects to CLN via the Bitcoin stack network (10.20.0.2:3001).
stack-bitcoin/config/RTL-Config.json is a template; on ./bastion up it is
seeded to stack-bitcoin/data/rtl/RTL-Config.json and an access rune is minted
from CLN into stack-bitcoin/data/rtl/access.rune (LIGHTNING_RUNE="β¦", mode
600) β both only if absent. If you change CLN RPC credentials or ports,
edit the live copy under data/. See stack-bitcoin/README.md.
General troubleshooting steps for common issues. Always check container logs first (docker logs <container>).
The commands below assume you are in the project root and have the necessary permissions to run Docker commands. Adjust paths and container names as needed based on your specific setup.
# CLN backup plugin backup not initialized - ensure USB mount is correct and accessible
# Example command to initialize backup plugin with mounted USB path:
docker run --rm -it -v $(pwd)/stack-bitcoin/data/cln:~/.lightning/bitcoin -v /mnt/backup_cln:/backup_usb --entrypoint /usr/local/bin/backup/backup-cli lightningd-custom:latest init --lightning-dir ~/.lightning/bitcoin file:///backup_usb/backup.sqlite.bkp
# CLN backup plugin - restore from backup file
docker run --rm -it -v $(pwd)/stack-bitcoin/data/cln:~/.lightning/bitcoin -v /mnt/backup_cln:/backup_usb --entrypoint /usr/local/bin/backup/backup-cli lightningd-custom:latest restore file:///backup_usb/backup.sqlite.bkp --lightning-dir ~/.lightning/bitcoin
# CLN backup plugin - compact backup file
docker exec lightningd lightning-cli backup-compact
# CLN not connecting to Bitcoin
docker logs lightningd
docker exec lightningd ping -c 3 10.20.0.3
# RTL cannot reach CLN
docker logs rtl
docker exec lightningd lightning-cli getinfo
# DNS issues
docker exec unbound dig @127.0.0.1 google.com
# TOR connectivity (config restart to refresh circuits)
docker kill --signal=HUP tor
docker logs tor
# TOR connectivity (delete Tor state for fresh circuits)
docker stop tor
docker volume rm bastion-tor-data
docker start tor
# Container resource usage
docker stats
df -hEach stack has a private Docker subnet. Cross-stack dependencies use the restricted
bastion-transit network (10.254.0.0/24). Only 51820/udp (WireGuard) is meant
to face the internet; the Hub ports are open on every interface on purpose and
the host firewall is the ACL β see "Access model & firewall" above and
docs/firewall.md.
Internal isolation (never published on the host):
- Bitcoin RPC:
10.20.0.3:8332(Bitcoin stack network only) - CLN P2P / TEOS API: only on
bastion-transit(10.254.0.10:9735/10.254.0.11:9814), reachable only through their Tor onion services - CCR gateway:
ccr:8080(AI stack network only; the management UI on3458is the only CCR port published) - CodeDeck bridge: no published host port; relay traffic uses
bastion-transit - Tor SOCKS/control (
9050/9051):bastion-transitonly, no host publish
Recommended:
- Change default passwords (Grafana
admin:admin, Pi-hole, Bitcoin RPC) - Keep
.gitignore/secrets/protected; never commitbastion.conf - Use WireGuard for remote access; apply docs/firewall.md before exposing the host
| Component | Version |
|---|---|
| Bitcoin Core | v26.0 |
| Core Lightning | v25.12.1 |
| RTL | v0.15.8 |
| Claude Code Router | pinned commit ec9fc53 of the CCR fork (CCR_REF in stack-ai/Dockerfile.ccr) |
| CodeDeck+ bridge | v0.11.1 |
| CLN Plugins: | |
| clboss | 95d195f8 |
| watchtower-client | be344ecc |
| backup | cb3adab |
| trustedcoin | v0.8.6 (replaces the disabled bcli) |
- Bitcoin: https://bitcoin.org
- Core Lightning: https://github.com/ElementsProject/lightning
- Docker: https://docs.docker.com
- Block explorer: https://mempool.space
Software provided as-is. Use at your own risk. Change defaults immediately. Never commit secrets.