A definitive, copy-pasteable guide to standing up a private, production freeq
instance for your company on a single host, from a fresh clone.
Audience: a competent engineer/sysadmin who has never deployed freeq. Goal: the
simplest reliable structure that works.
This guide targets a single-host, single-domain, non-federated deployment
— the right topology for a private company chat server. Federation (server-to-server
/did:web) and the standalone OAuth broker are not needed for this and are
intentionally left off. See §8 Optional extras if you
think you need them.
For a private single-host install you run exactly three things:
| Component | What it is | Where it comes from |
|---|---|---|
| freeq-server | One Rust binary. Handles IRC (plain TCP + TLS), the WebSocket/HTTP listener, the REST API, and the AT Protocol (Bluesky) OAuth login flow — all in-process. | cargo build --release -p freeq-server |
| Web client | Static React/Vite build (HTML/JS/CSS). Served by the freeq-server's web listener. | cd freeq-app && npm run build → freeq-app/dist |
| nginx | Reverse proxy that terminates TLS on 443 and forwards to the server's web listener. | Your distro's nginx package + certbot |
You do NOT need the separate freeq-auth-broker service. The server has a
built-in OAuth flow. The standalone broker only exists for the setup where login
lives on a different subdomain (e.g. production irc.freeq.at uses auth.freeq.at).
For a single domain, the web client detects that the login origin equals the web
origin and lets the server handle OAuth directly. (See freeq-app/src/components/ConnectScreen.tsx:
"If brokerOrigin is the same as webOrigin, there's no external broker — the server
handles auth directly via /auth/login.")
Internet
│
┌───────────────┼────────────────────────┐
│ │ │
443 (HTTPS) 6697 (IRC+TLS) 6667 (IRC plain)
│ native IRC clients (optional; LAN/VPN only)
▼ │ │
┌──────────────────┐ │ │
│ nginx │ │ │
│ TLS termination │ │ │
└────────┬─────────┘ │ │
│ proxy_pass │ TLS handled │
│ 127.0.0.1:8080 │ by the server itself │
▼ ▼ ▼
┌────────────────────────────────────────────────────────┐
│ freeq-server │
│ • web listener (127.0.0.1:8080): web client, /irc WS, │
│ /api/v1/*, /auth/login (built-in OAuth) │
│ • TLS IRC listener (0.0.0.0:6697) │
│ • plain IRC listener (0.0.0.0:6667) [optional] │
└───────────────────────────┬─────────────────────────────┘
│ reads/writes
▼
/var/lib/freeq/ (data-dir)
irc.db irc-policy.db media/
db-encryption-key.secret msg-signing-key.secret
verifier-signing-key.secret iroh-key.secret
nginx terminates TLS for the browser (443). Native IRC clients (irssi, WeeChat,
HexChat) connect straight to the server's own TLS listener on 6697 — nginx
does not proxy raw IRC, so the server needs the cert directly for that port.
chat.example.com, with an A/AAAA record pointing at the host's public IP. Browser OAuth requires HTTPS on a real domain — did:web/PDS redirect URIs will not work over plain HTTP or a bare IP.build-essential pkg-config libssl-dev curl git nginx certbot python3-certbot-nginx sqlite3sudo apt-get update
sudo apt-get install -y build-essential pkg-config libssl-dev curl git \
nginx certbot python3-certbot-nginx sqlite3
# Rust (if not present)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
# Node 20 (if not present)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
Throughout, replace chat.example.com with your domain.
sudo adduser --system --group --no-create-home freeq
sudo mkdir -p /opt/freeq /var/lib/freeq
sudo chown freeq:freeq /var/lib/freeq
cd /opt
sudo chown "$USER" /opt/freeq
git clone https://github.com/chad/freeq /opt/freeq
cd /opt/freeq
cargo build --release -p freeq-server
# Binary lands at /opt/freeq/target/release/freeq-server
cd /opt/freeq/freeq-app
npm ci # a prebuild hook also builds the bundled @freeq/sdk
npm run build # outputs to /opt/freeq/freeq-app/dist
cd /opt/freeq
The web client is origin-relative: it talks to /irc, /api/v1/*, and /auth/*
on whatever host it is served from, so there is nothing to configure in the
build for a single-domain install.
All key files are auto-generated on first run inside --data-dir. You don't
have to pre-create them. The only secret you must supply yourself is optional
operator/broker config:
# Server operator password for the IRC OPER command (optional but recommended)
openssl rand -hex 24 # copy this into OPER_PASSWORD below
The auto-generated files (in /var/lib/freeq/) are:
| File | Purpose |
|---|---|
db-encryption-key.secret |
AES-256-GCM key encrypting message text at rest. Losing it makes all history unreadable. |
msg-signing-key.secret |
Server's ed25519 message-signing key (fallback signing). |
verifier-signing-key.secret |
Credential-verifier signing key. |
iroh-key.secret |
Federation transport identity — only created if you enable --iroh. |
Back these up the moment they exist (see §6). They are
.gitignored — never commit them.
Point DNS at the host first, then:
# Temporary minimal nginx vhost so certbot can complete the HTTP-01 challenge:
sudo tee /etc/nginx/sites-available/freeq.conf >/dev/null <<'EOF'
server {
listen 80;
server_name chat.example.com;
root /var/www/html;
}
EOF
sudo ln -sf /etc/nginx/sites-available/freeq.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --nginx -d chat.example.com --agree-tos -m you@example.com --non-interactive
This writes /etc/letsencrypt/live/chat.example.com/{fullchain,privkey}.pem.
Certbot installs an auto-renew timer.
Let the freeq user read the cert (needed for the 6697 IRC TLS listener):
sudo groupadd -f ssl-cert
sudo usermod -aG ssl-cert freeq
sudo chgrp ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chmod g+x /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp -R ssl-cert /etc/letsencrypt/archive/chat.example.com
sudo chmod -R g+rX /etc/letsencrypt/archive/chat.example.com
Replace the temporary vhost with the real one (adapted from the repo's
deploy/nginx.conf.template):
# /etc/nginx/sites-available/freeq.conf
server {
listen 443 ssl http2;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# WebSocket IRC transport
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Uploads (authenticated users push media to their PDS via the server)
location /api/v1/upload {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 12M;
}
# Hashed, immutable JS/CSS assets
location /assets/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Web client, REST API, and the built-in OAuth endpoints (/auth/login, /auth/callback, /client-metadata.json)
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
client_max_body_size 12M;
add_header Cache-Control "no-cache";
}
}
server {
listen 80;
server_name chat.example.com;
return 301 https://$host$request_uri;
}
Important: keep
proxy_set_header Host $host;exactly as written. The server
builds OAuthredirect_uris from the incoming Host header, so it must see the
real public hostname or Bluesky login will fail.
sudo nginx -t && sudo systemctl reload nginx
# /etc/systemd/system/freeq-server.service
[Unit]
Description=freeq IRC server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=freeq
Group=freeq
WorkingDirectory=/opt/freeq
ExecStart=/opt/freeq/target/release/freeq-server \
--listen-addr 127.0.0.1:6667 \
--tls-listen-addr 0.0.0.0:6697 \
--tls-cert /etc/letsencrypt/live/chat.example.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/chat.example.com/privkey.pem \
--web-addr 127.0.0.1:8080 \
--web-static-dir /opt/freeq/freeq-app/dist \
--db-path /var/lib/freeq/irc.db \
--data-dir /var/lib/freeq \
--server-name chat.example.com \
--motd "Welcome to Example Corp chat"
EnvironmentFile=-/etc/freeq/secrets
Restart=on-failure
RestartSec=5
# Hardening
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/freeq
ReadOnlyPaths=/etc/letsencrypt
[Install]
WantedBy=multi-user.target
Notes on the flags above:
- --listen-addr 127.0.0.1:6667 binds plain IRC to loopback only, so it is
not exposed to the internet. Change to 0.0.0.0:6667 only if you want plain IRC
on the LAN/VPN (and firewall it).
- --tls-listen-addr 0.0.0.0:6697 is the public port for native IRC clients over TLS.
- --web-addr 127.0.0.1:8080 is loopback because nginx fronts it.
Put secrets in /etc/freeq/secrets (loaded via EnvironmentFile):
sudo mkdir -p /etc/freeq
sudo tee /etc/freeq/secrets >/dev/null <<'EOF'
OPER_PASSWORD=<paste the openssl rand output>
# Optionally auto-op specific Bluesky DIDs on connect:
# OPER_DIDS=did:plc:youradmin,did:plc:anotheradmin
RUST_LOG=info
EOF
sudo chown root:freeq /etc/freeq/secrets
sudo chmod 640 /etc/freeq/secrets
sudo chown -R freeq:freeq /opt/freeq/target /var/lib/freeq
sudo systemctl daemon-reload
sudo systemctl enable --now freeq-server
sudo systemctl status freeq-server --no-pager
sudo journalctl -u freeq-server -f # watch it come up
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 6697/tcp # IRC over TLS for native clients
# Do NOT open 6667 (plain) or 8080 to the internet.
sudo ufw enable
Done. Open https://chat.example.com in a browser.
Every flag below is a real freeq-server flag (from freeq-server/src/config.rs).
Flags marked (env) can also be set via the named environment variable.
| Flag | Default | Recommended (private) | Description |
|---|---|---|---|
--listen-addr (alias --bind) |
127.0.0.1:6667 |
127.0.0.1:6667 |
Plain-text IRC listener. Keep on loopback/LAN. |
--tls-listen-addr |
127.0.0.1:6697 |
0.0.0.0:6697 |
IRC-over-TLS listener. Active only if --tls-cert and --tls-key are set. |
--tls-cert / --tls-key |
none | your Let's Encrypt PEMs | Enables the TLS IRC listener. |
--web-addr |
none (no web) | 127.0.0.1:8080 |
HTTP/WebSocket/REST listener. Required for the web client. Loopback behind nginx. |
--web-static-dir |
none | /opt/freeq/freeq-app/dist |
Directory of the built web client. Served at /. |
| Flag / Env | Default | Recommended | Description |
|---|---|---|---|
--server-name |
freeq |
your domain | Name shown in IRC numerics/messages. |
--db-path |
none (in-memory) | /var/lib/freeq/irc.db |
SQLite DB. If unset, nothing persists across restarts. |
--data-dir |
parent of --db-path |
/var/lib/freeq |
Where the *.secret keys and iroh state live. |
--max-messages-per-channel |
10000 |
10000 (0 = unlimited) |
Oldest messages pruned beyond this. |
--challenge-timeout-secs |
60 |
60 |
SASL challenge validity window. |
--motd / --motd-file |
none | a short welcome | Message of the day. --motd-file overrides --motd. |
--oper-password (env OPER_PASSWORD) |
none | set a strong one | Enables the IRC OPER <name> <password> command → global operator. |
--oper-dids (env OPER_DIDS) |
none | your admins' DIDs | Comma-separated DIDs auto-granted operator on connect. |
| Env | Description |
|---|---|
RUST_LOG |
Log level, e.g. info, or freeq_server::s2s=debug,info. |
FREEQ_LOG_JSON=1 |
Emit structured JSON logs (for aggregation). |
BROKER_SHARED_SECRET |
Leave unset for single-host. Only needed if you run a separate auth-broker subdomain; enables the server's /auth/broker/* push endpoints. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
Optional — only for the GitHub credential verifier feature. |
| Flag | Default | Private recommendation |
|---|---|---|
--iroh |
off | off — do not enable. No S2S, no iroh-key.secret, nothing to peer. |
--iroh-port |
random | n/a |
--s2s-peers |
none | leave empty |
--s2s-allowed-peers |
none (= open federation if --iroh is on) |
leave empty |
--s2s-peer-trust |
none | leave empty |
--server-did |
none | leave empty (only needed for federation identity) |
--require-did-for-ops |
off | n/a unless federating |
Hardening note:
--s2s-allowed-peersempty means any peer may connect —
but only if--irohis enabled. Since a private instance leaves--irohoff
entirely, there is no federation surface at all. That is the safe default.
For anything beyond these, rate-limit at nginx.
TLS / web:
curl -I https://chat.example.com/ # 200, serves the web client
curl -s https://chat.example.com/api/v1/health # health JSON
Browser + OAuth SASL:
1. Open https://chat.example.com.
2. Enter a Bluesky handle (e.g. you.bsky.social) and click Sign in with Bluesky.
You'll be redirected to your PDS's OAuth page, then back. On success you land in #freeq.
3. This proves the built-in OAuth flow works end-to-end (challenge → PDS sign → SASL 903).
Native IRC client over TLS (guest + SASL):
# Guest connect (no auth) — proves standard IRC still works:
# Server: chat.example.com Port: 6697 (TLS)
# In WeeChat, for example:
/server add mycorp chat.example.com/6697 -ssl
/connect mycorp
/join #general
Send a message and confirm it appears in the web client too.
Confirm auth binding: after signing in, /whois <yournick> should show your
cloaked host as freeq/plc/xxxxxxxx (authenticated) rather than freeq/guest.
Check logs: sudo journalctl -u freeq-server -n 100 --no-pager — you should
see Starting IRC server, TLS enabled, and HTTP/WebSocket enabled.
| Item | Path | Why |
|---|---|---|
| Message/channel DB | /var/lib/freeq/irc.db |
All history, channels, users. |
| Policy DB | /var/lib/freeq/irc-policy.db |
Policy rules, credentials. |
| Encryption key | /var/lib/freeq/db-encryption-key.secret |
Without it, irc.db history is permanently unreadable. |
| Signing keys | /var/lib/freeq/*.secret |
Server identity/signing continuity. |
| Config | /etc/freeq/secrets, the systemd unit, nginx vhost |
Reproducibility. |
# Hot DB backup (safe while running):
sudo -u freeq sqlite3 /var/lib/freeq/irc.db \
"VACUUM INTO '/var/backups/freeq/irc-$(date +%F).db'"
sudo -u freeq sqlite3 /var/lib/freeq/irc-policy.db \
"VACUUM INTO '/var/backups/freeq/irc-policy-$(date +%F).db'"
# Keys (do this once; they don't change unless you rotate):
sudo cp /var/lib/freeq/*.secret /var/backups/freeq/keys/
sudo chmod 600 /var/backups/freeq/keys/*
Store the *.secret backups off-host and encrypted. Treat db-encryption-key.secret
like a database master password.
sudo systemctl restart freeq-server
irc.freeq.at is deployed)¶cd /opt/freeq
sudo -u freeq git pull --ff-only
cargo build --release -p freeq-server
cd freeq-app && npm ci && npm run build && cd ..
sudo systemctl restart freeq-server
(This is exactly the git pull → cargo build → npm build → systemctl restart
flow the maintainer uses; see deploy/deploy.sh.) Certbot renews TLS automatically;
after a renewal the server picks up the new cert on its next restart.
sudo systemctl stop freeq-server*.db files into /var/lib/freeq/*.secret files into /var/lib/freeq/ (must match the DB they encrypted)sudo chown freeq:freeq /var/lib/freeq/*sudo systemctl start freeq-server127.0.0.1.--iroh. With it off there is no S2S peer surface and no --s2s-allowed-peers "open by default" risk./etc/freeq/secrets is 640 root:freeq; /var/lib/freeq/*.secret are 600 freeq:freeq. Never commit them (.gitignore covers *.secret/*.pem, but verify).OPER_PASSWORD and a curated OPER_DIDS list of just your admins.:80 → 301 https block does this). Native clients use 6697 only.NoNewPrivileges, ProtectSystem=strict, ReadWritePaths=/var/lib/freeq, ReadOnlyPaths=/etc/letsencrypt.+i) and/or keyed (+k), and appoint ops. There is no server-wide "members only" flag today.FREEQ_LOG_JSON=1 and ship journald to your SIEM.These are real limitations discovered in the code — plan around them:
+i) — not by anallow/deny / mTLS layer.freeq user must be able tossl-cert group steps in §3.5). Forgetting this makes 6697freeq-auth-broker is only for split-origin login. You canBROKER_SHARED_SECRET, the broker needsFREEQ_SERVER_URL/BROKER_PUBLIC_URL/BROKER_DB_PATH env vars, and its CORSfreeq-auth-broker/src/main.rs (you'ddid:web + iroh S2S)¶Only if you want to peer with other freeq servers. Enable --iroh, set a
--server-did did:web:chat.example.com, publish /.well-known/did.json, and
restrict peers with --s2s-allowed-peers. Full details:
docs/server-did.md and docs/federation.md.
For a private company instance, skip this.
Only if login must live on a different subdomain than the web app (production does
this: auth.freeq.at vs irc.freeq.at). Run the freeq-auth-broker binary with
BROKER_SHARED_SECRET, FREEQ_SERVER_URL, BROKER_PUBLIC_URL, and BROKER_DB_PATH,
set the same BROKER_SHARED_SECRET on the server, and edit the broker's hardcoded
CORS list. Not needed for the single-domain setup in this guide.
The repo ships a Dockerfile + docker-compose.yml that build the server + web
client into one image. docker compose up -d runs the server; --profile with-tls
adds nginx; --profile with-broker adds the (optional) broker. The bare-metal
systemd path in this guide is preferred for a small private instance because it's
easier to back up and inspect, but Compose is a valid one-command alternative.
| Port | Bind | Purpose | Public? |
|---|---|---|---|
| 443 | nginx | HTTPS web client + WS IRC + REST + OAuth | yes |
| 80 | nginx | HTTP → HTTPS redirect + certbot | yes |
| 6697 | freeq-server | IRC over TLS (native clients) | yes |
| 6667 | freeq-server | IRC plaintext | no (loopback/LAN) |
| 8080 | freeq-server | web/WS/REST (behind nginx) | no (loopback) |
| Path | What |
|---|---|
/opt/freeq |
source + built binary + web dist/ |
/var/lib/freeq |
DBs, *.secret keys, media/ |
/etc/freeq/secrets |
env vars for systemd |
/etc/systemd/system/freeq-server.service |
unit |
/etc/nginx/sites-available/freeq.conf |
reverse proxy |
/etc/letsencrypt/live/chat.example.com/ |
TLS cert + key |