Run your own freeq server with TLS, the web client, and optional federation.
Just want it running? The Self-Hosting Quickstart
covers the three simplest paths (Miren, Docker, single binary) with
copy-paste commands. This guide is the full reference.
The default self-hosting path. Miren is a container
platform you run on your own server. The repo ships a ready Miren config at
.miren/app.toml — three commands build and deploy
the IRC server and the web client, with HTTPS routing, automatic Let's
Encrypt certs, a persistent managed disk for the database and keys, and a
hard pin to one instance (IRC state is in-process — no autoscaling):
# Prerequisites: a Miren server + the miren CLI installed and logged in
# (host firewall: TCP 80/443 + UDP 8443 open)
git clone https://github.com/freeq-irc/freeq
cd freeq
miren deploy -e FREEQ_SERVER_NAME=irc.example.com
miren route set irc.example.com freeq
miren env set -s OPER_PASSWORD # optional, masked prompt
Then point DNS at your cluster — a CNAME to its *.miren.systems hostname
for subdomains, or ALIAS/ANAME/A at the apex. The web client is served at
the root, WebSocket IRC at /irc, REST API at /api/v1/*; native TCP IRC
(6667) is a documented opt-in.
The full 10-minute walkthrough — DNS options, secrets, where the SQLite
data lives and how to back it up, upgrades, the auth broker, and federation
flags — is in deploy/miren/README.md.
If you don't run Miren, Docker Compose gives you the same stack (server +
web client, with optional nginx TLS termination and OAuth broker):
git clone https://github.com/freeq-irc/freeq
cd freeq
cp .env.example .env # edit with your values
docker compose up -d
For TLS termination with nginx:
docker compose --profile with-tls up -d
The OAuth broker (AT Protocol web login) is embedded in the server by
default — no extra service needed. To run it as a separate service instead
(separate auth domain, sessions that survive restarts):
docker compose --profile with-broker up -d
Plain Docker, without compose (builds from source — prebuilt
ghcr.io/freeq-irc/freeq images arrive with the first tagged release):
docker build -t freeq .
docker run -d \
-p 6667:6667 -p 8080:8080 \
-v freeq-data:/data \
freeq
git clone https://github.com/freeq-irc/freeq
cd freeq
cargo build --release -p freeq-server
# Start with defaults (port 6667, no TLS, in-memory)
./target/release/freeq-server --bind 0.0.0.0:6667
For a bare-VPS install with systemd + nginx + certbot, see
deploy/README.md (./deploy/setup.sh yourdomain.com --nginx).
| Flag | Default | Description |
|---|---|---|
--bind |
127.0.0.1:6667 |
Plain TCP listener |
--tls-bind |
127.0.0.1:6697 |
TLS listener (requires cert + key) |
--web-addr |
(none) | HTTP/WebSocket listener |
freeq-server \
--bind 0.0.0.0:6667 \
--tls-bind 0.0.0.0:6697 \
--tls-cert /path/to/cert.pem \
--tls-key /path/to/key.pem
Use Let's Encrypt with auto-renewal for production. See the nginx config
below for TLS termination at the reverse proxy instead.
cd freeq-app && npm install && npm run build && cd ..
freeq-server \
--bind 0.0.0.0:6667 \
--web-addr 0.0.0.0:8080 \
--web-static-dir freeq-app/dist
The web client is served at the root path. WebSocket IRC is at /irc.
REST API endpoints are at /api/v1/*.
freeq-server --db-path /data/irc.db --data-dir /data
Or keep everything in a file instead of a flag list. Every flag is also a TOML key under its underscore name; precedence is CLI flag > environment variable > file > default, and an unknown key is a startup error naming the key (typos fail loudly rather than being silently ignored):
# /etc/freeq/server.toml
listen_addr = "0.0.0.0:6667"
web_addr = "0.0.0.0:8080"
db_path = "/data/irc.db"
data_dir = "/data"
server_name = "irc.example.com"
iroh = true
s2s_allowed_peers = ["44f1415c..."]
# Where each federation peer serves its users' signing keys:
[s2s_peer_api]
"44f1415c..." = "https://irc.example.com"
freeq-server --config /etc/freeq/server.toml
--migrate-to stays CLI-only on purpose — a config file that migrates-and-exits on every boot would be a footgun. The repo ships a complete commented example as server.toml.example, kept in sync with the schema by a test.
| Flag | Default | Description |
|---|---|---|
--config |
(none) | TOML file of options; flags and env vars override it |
--check-config |
Validate configuration and exit — run before a restart to catch bad edits | |
--db-path |
(none — in-memory) | SQLite database file |
--migrate-to |
(none) | Run the schema ladder to this version and exit (see Schema migrations) |
--data-dir |
parent of --db-path |
Directory for keys and iroh state |
--max-messages-per-channel |
10000 |
Prune oldest messages beyond this count |
| Flag / Env | Description |
|---|---|
--server-name |
IRC server name (appears in messages) |
--challenge-timeout-secs |
SASL challenge validity window (default: 60) |
--oper-password / OPER_PASSWORD |
Enable OPER command with this password |
--oper-dids / OPER_DIDS |
DIDs auto-granted server operator on connect |
BROKER_SHARED_SECRET |
HMAC secret shared with auth broker |
GITHUB_CLIENT_ID |
GitHub OAuth for credential verifier |
GITHUB_CLIENT_SECRET |
GitHub OAuth secret |
freeq-server \
--iroh \
--s2s-peers <peer-id> \
--s2s-allowed-peers <peer-id> \
--s2s-peer-api <peer-id>=https://peer.example.com
| Flag | Default | Description |
|---|---|---|
--iroh |
off | Enable iroh QUIC transport |
--iroh-port |
random | UDP port for iroh |
--s2s-peers |
(none) | Peer endpoint IDs to connect to on startup |
--s2s-allowed-peers |
(none — open) | Allowlist for incoming peer connections |
--s2s-peer-api |
(none — peer signatures stay uncheckable) | Where each peer serves its users' signing keys: <endpoint-id>=<https://base> (the peer's REST API base URL). Deliberately operator configuration, never peer-announced |
--s2s-peer-trust |
(none) | Trust levels per peer: id:full, id:relay, id:readonly |
--server-did |
(none) | Server DID for federation identity (e.g. did:web:irc.example.com) |
See Federation, S2S Auth, Server DID Setup, and Security Guide for details.
freeq-server --motd "Welcome to my server"
# or
freeq-server --motd-file /path/to/motd.txt
server {
listen 443 ssl http2;
server_name irc.example.com;
ssl_certificate /etc/letsencrypt/live/irc.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/irc.example.com/privkey.pem;
location /irc {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
[Unit]
Description=freeq IRC server
After=network.target
[Service]
Type=simple
User=freeq
WorkingDirectory=/opt/freeq
ExecStart=/opt/freeq/freeq-server \
--bind 0.0.0.0:6667 \
--tls-bind 0.0.0.0:6697 \
--tls-cert /etc/letsencrypt/live/irc.example.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/irc.example.com/privkey.pem \
--web-addr 127.0.0.1:8080 \
--web-static-dir /opt/freeq/freeq-app/dist \
--db-path /opt/freeq/data/irc.db \
--data-dir /opt/freeq/data \
--server-name irc.example.com
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
| File | Purpose |
|---|---|
irc.db |
Message history, channels, user data (SQLite) |
irc-policy.db |
Policy rules and credentials (SQLite) |
msg-signing-key.secret |
Server message signing key (ed25519) |
verifier-signing-key.secret |
Credential verifier signing key |
db-encryption-key.secret |
Database encryption-at-rest key |
iroh-key.secret |
iroh QUIC endpoint identity key |
All key files are generated automatically on first run.
⚠️ WARNING: Never commit
*.secretor*.pem/*.keyfiles to version
control. They are excluded by.gitignorebut always verify before pushing.
See Security Hardening Guide for key rotation procedures.
Message text is encrypted with AES-256-GCM before writing to SQLite. The key
is stored in db-encryption-key.secret. Messages are transparently decrypted
on read. Back up this key — losing it makes all stored messages unreadable.
# Hot backup (SQLite VACUUM INTO)
sqlite3 /data/irc.db "VACUUM INTO '/backup/irc-$(date +%Y%m%d).db'"
sqlite3 /data/irc-policy.db "VACUUM INTO '/backup/irc-policy-$(date +%Y%m%d).db'"
Or simply copy the .db file while the server is stopped.
# Back up all key files
cp /data/*.secret /backup/keys/
chmod 600 /backup/keys/*
Critical: The
db-encryption-key.secretfile is required to read
encrypted messages. If lost, message history is irrecoverable.
.db files to --db-path location.secret files to --data-dir locationThe database schema is versioned, and startup migrates it forward automatically — upgrading the server never needs a manual step. A binary refuses to open a database stamped with a newer schema than it knows, so rolling back to an older binary requires downgrading the schema first:
# Stop the server, then run the ladder down to the version the old binary expects:
freeq-server --db-path /data/irc.db --migrate-to 2
# Then start the older binary as usual.
The command prints the version it moved from and to, then exits without starting the server. Downgrades stop with an error at any migration that is irreversible by design (the database is left at the last version reached) — in that case, restore from backup instead. Take a backup before any downgrade regardless.
These are hardcoded. For additional rate limiting, configure your reverse proxy.
# Default: human-readable
RUST_LOG=info freeq-server ...
# Structured JSON (for log aggregation)
RUST_LOG=info FREEQ_LOG_JSON=1 freeq-server ...
# Debug logging for specific modules
RUST_LOG=freeq_server::s2s=debug,info freeq-server ...
See Security Hardening Guide for: