The caddy-init service's `command:` used a YAML FOLDED scalar (`>`). Base
indent is set by `sh -c '` at 6; the closing `}` and the heredoc's `EOF`
were BOTH at 6, so YAML folded them into `} EOF`. The heredoc opened with
`<<EOF` therefore never saw a terminator line.
Confirmed by parsing the generated compose file: the command contains
`} EOF` and no bare EOF line. Feeding the resulting script to sh:
here-document at line 3 delimited by end-of-file (wanted `EOF')
syntax error: unexpected end of file (rc=2)
So caddy-init exited 2 before its `>` redirect ever ran -- no Caddyfile was
ever written, on any deploy. The `if [ ! -f /etc/caddy/Caddyfile ]` guard
never executed at all. caddy gates on
`caddy-init: condition: service_completed_successfully`, and smp-server and
xftp-server gate on caddy being healthy, so nothing in the stack started.
`docker compose up -d` returned non-zero and `set -euo pipefail` aborted the
script before the final report. The same failure hit every entry point:
automations.sh, cloud-init, the OpenRC unit's start() (so every boot), and
restore.sh -- which restores the same broken compose file backup.sh saved.
Generate the Caddyfile from the deploy shell instead and drop caddy-init
entirely, which removes the whole class of problem. Unlike the old
first-run-only guard this also applies a changed DOMAIN, ACME_EMAIL or
KEY_TYPE on a re-run rather than freezing them at the first deploy.
The heredoc here is deliberately unquoted so ${DOMAIN}/${ACME_EMAIL}/
${KEY_TYPE} expand; Caddy's {uri} has no `$` and survives. `cmp -s` stays
inside an `if` condition -- as `cmp -s A B && CADDY_CHANGED=1` it would trip
set -e whenever the files matched. Caddy is restarted only when the file
actually changed AND compose did not already replace the container, since a
bind-mounted file's contents are not part of the compose config hash and
restarting seconds after a first start would interrupt initial ACME issuance.
The Caddyfile is now real on-disk state rather than something a container
regenerates, so it is added to backup.sh's targets and restore.sh's file
list; without that, a restore would start caddy against an empty
./caddy_conf and the servers would never pass their health gate.
Verified: first deploy writes it and reports no change; an identical re-run
reports no change; a corrected DOMAIN rewrites it and flags the restart;
{uri} survives and no .new file is left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SimpleX Relay Server Deployment
Complete automated deployment for SimpleX Chat relay servers on Alpine Linux with post-quantum SSH hardening, Tor hidden services, and encrypted backups.
Quick Start
1. Clone This Repository
The installer fetches its scripts straight from this monorepo's layout:
automations/
├── scripts/
│ └── harden-ssh.sh # generic SSH hardening (PQ KEX + Ed25519)
├── deployments/simplex/
│ ├── deploy-simplex.sh # SMP + XFTP + Tor deployment
│ ├── backup.sh # age-encrypted backup creation
│ ├── restore.sh # disaster recovery from backup
│ ├── install-simplex.sh # master installer (cloud-init compatible)
│ ├── cloud-init.yml # example cloud-init configuration
│ └── README.md # this file
└── globals/
└── age-pubkey.txt # your age public key(s) for backups
Point REPO_URL at your fork of this repo. The installer resolves
harden-ssh.sh under scripts/ and the simplex scripts under
deployments/simplex/ automatically (overridable via HARDEN_PATH /
SIMPLEX_PATH).
2. Generate Backup Keys
# Generate age keypair for backups
age-keygen -o backup-private-key.txt
# Save the public key to the shared globals/ folder
echo "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p" > globals/age-pubkey.txt
# Store the private key securely (NOT in your repo)
cp backup-private-key.txt ~/safe-location/
3. Deploy via Cloud-Init
Create a cloud instance with this user-data:
#cloud-config
runcmd:
- |
curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/deployments/simplex/install-simplex.sh | \
REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git \
DOMAIN=relay.example.com \
ACME_EMAIL=admin@example.com \
XFTP_QUOTA=100gb \
SSH_PORT=2222 \
ALLOWED_IP=1.2.3.4 \
bash
4. Manual Deployment
# On a fresh Alpine Linux host:
curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/deployments/simplex/install-simplex.sh | \
REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git \
DOMAIN=relay.example.com \
ACME_EMAIL=admin@example.com \
XFTP_QUOTA=50gb \
SSH_PORT=2222 \
bash
Configuration Options
| Variable | Required | Default | Description |
|---|---|---|---|
REPO_URL |
✅ | Git repository URL containing deployment scripts | |
DOMAIN |
✅ | Apex domain (creates smp.DOMAIN, xftp.DOMAIN) | |
ACME_EMAIL |
✅ | Email for Let's Encrypt registration | |
XFTP_QUOTA |
50gb | Disk quota for file storage | |
SSH_PORT |
22 | SSH port (recommend changing from default) | |
ALLOWED_IP |
Your IP to whitelist in sshguard | ||
KEY_TYPE |
rsa4096 | Caddy TLS key type (rsa4096, p384, ed25519) | |
SMP_PASS |
Optional password for SMP queue creation | ||
XFTP_PASS |
Optional password for XFTP uploads | ||
SKIP_PROMPTS |
0 | Set to 1 for non-interactive installation |
What Gets Deployed
Security Features
- Post-quantum SSH: Hybrid KEX (mlkem768x25519, sntrup761x25519) + Ed25519 keys
- Minimal attack surface: Only SSH terminal + SFTP, no forwarding/tunnels
- Firewall: awall with explicit allow-list for required ports only
- Brute-force protection: sshguard with progressive IP bans
SimpleX Infrastructure
- SMP server: Message relay on port 5223
- XFTP server: File transfer on port 5443
- Tor hidden services: .onion addresses for both servers
- TLS termination: Caddy with Let's Encrypt and strong crypto
- Docker compose: Orchestrated deployment with health checks
Operational Features
- Encrypted backups: All private keys backed up with age encryption
- CA key removal: Private keys removed from disk after backup
- Service monitoring: OpenRC integration with auto-restart
- Address discovery: Scripts to show server fingerprints and .onion URLs
Server Addresses
After deployment, your relay will be accessible at:
# Clearnet
smp://FINGERPRINT@smp.yourdomain.com
xftp://FINGERPRINT@xftp.yourdomain.com
# Tor (clients with Orbot/Tor)
smp://FINGERPRINT@smp.yourdomain.com,ONIONADDRESS.onion
xftp://FINGERPRINT@xftp.yourdomain.com,ONIONADDRESS.onion
Get the full addresses with: cd /opt/simplex && ./print-addresses.sh
Backup & Recovery
Creating Backups
# Manual backup (on the server)
AGE_RECIPIENT_FILE=/path/to/age-pubkey.txt bash backup.sh
# Or with a single recipient
AGE_RECIPIENT=age1ql3z7... bash backup.sh
Disaster Recovery
# 1. Fresh Alpine install
# 2. Upload restore script and backup
scp restore.sh backup-20250101-120000.tar.gz.age root@new-host:/root/
# 3. Restore (preserves all server identities)
ssh root@new-host
bash restore.sh backup-20250101-120000.tar.gz.age
# 4. Update DNS to point at new host
DNS Configuration
Create these DNS records before deployment:
smp.yourdomain.com. 300 IN A 1.2.3.4
xftp.yourdomain.com. 300 IN A 1.2.3.4
Replace 1.2.3.4 with your server's IP address.
Architecture
Internet
↓
[Caddy :80,:443] ← Let's Encrypt ACME
↓
[Docker Network]
├─ [SMP Server :5223] ← Tor Hidden Service
└─ [XFTP Server :5443] ← Tor Hidden Service
- Ports 80/443: Caddy (HTTP redirect + ACME + info pages)
- Port 5223: SMP protocol (TCP, server's own TLS)
- Port 5443: XFTP protocol (TCP, server's own TLS)
- Port 22/2222: SSH (PQ KEX + Ed25519 only)
File Locations
- Deployment:
/opt/simplex/(docker-compose.yml, configs, scripts) - Server keys:
/opt/simplex/{smp,xftp}_configs/(CA keys removed after backup) - Tor keys:
/opt/simplex/{smp,xftp}_tor/(hidden service identity) - SSH config:
/etc/ssh/(hardened sshd_config + Ed25519 host key) - Firewall:
/etc/awall/optional/(JSON policy files) - Backups:
/tmp/simplex-backup-YYYYMMDD-HHMMSS.tar.gz.age
Security Notes
What's Quantum-Safe
- Session keys: PQ hybrid KEX protects against "store now, decrypt later"
- Authentication: Ed25519 keys (classical, but strongest practical choice today)
Network Hardening
- Only required ports open (22/2222, 80, 443, 5223, 5443)
- SSH locked to key-only auth, no forwarding, rate-limited
- sshguard blocks brute-force attempts with progressive delays
Key Management
- CA private keys backed up encrypted, then removed from disk
- Tor onion keys preserved for stable .onion addresses
- SSH host key preserved for stable fingerprint
- All backups encrypted with age (modern, simple, secure)
Troubleshooting
Check Service Status
cd /opt/simplex
docker compose ps # Container status
docker compose logs -f smp-server # SMP logs
docker compose logs -f xftp-server # XFTP logs
./print-addresses.sh # Show server addresses
SSH Issues
# Test SSH config
sshd -t
# View SSH attempts
journalctl -u sshd -f
# Check sshguard blocks
iptables -L sshguard -n
Firewall Issues
awall list # Show firewall policies
awall translate # Test policy compilation
iptables -L -n # Show active rules
Certificate Issues
docker exec simplex-caddy caddy list-certificates
docker logs simplex-caddy
Contributing
- Fork this repository
- Make your changes
- Test on a fresh Alpine instance
- Submit a pull request
License
This deployment is provided as-is for educational and operational use. The SimpleX Chat software itself is licensed under AGPL-3.0.