Artifact 50bc9cd57e73921242e90c5e833e448c2dfde368bc511e15843d82bf17998927:
- File
docs/SETUP.md
— part of check-in
[5e434ca14e]
at
2026-04-07 19:19:41
on branch trunk
— Dark theme overhaul: buttons, forms, chat, diffs, and page-specific fixes
Comprehensive dark-mode styling pass covering:
- Submenu link-buttons styled as pills with hover/active/focus states - Custom dark-themed checkboxes replacing browser defaults - Submit/action buttons with hover, active, and disabled states - Wiki editor: tab bar, toolbar layout, fieldsets - Chat: centered layout, blue-tinted bubbles, styled input/buttons, mobile - Timeline: fileage/tree-view hover overrides (was bright white) - Diff: subtle green/red ins/del, diffskip rows, SBS marker columns - Annotate: dark overlay on inline pastel backgrounds - Forum: search form layout, post containers, debug panel - Tickets: tktDspValue dark surface instead of light grey - Security audit: capsumRead/Write/Off with dark-appropriate colors - Search form: proper flex layout with text input priority - Sitemap dropdown: multi-column with mobile single-column fallback - Header: logo image from /logo instead of F text mark - span.error/mark: dark-mode yellow highlight treatment - Various Fossil built-in light-theme color overrides (user: dmestas, size: 16196) [annotate] [blame] [check-ins using]
fossil.craftdesign.group — Full Setup Guide
Complete instructions for reproducing this server from scratch on a fresh VPS.
Tested on: Ubuntu 24.04.3 LTS, aarch64 (ARM64)
Result: https://fossil.craftdesign.group — multi-repo Fossil SCM behind Cloudflare tunnel
Architecture
Internet
└── Cloudflare (fossil.craftdesign.group)
└── cloudflared tunnel (named: fossil)
└── Caddy :8090 (reverse proxy + static landing page)
├── GET / → /var/fossil/www/index.html
├── GET /a11y.js → /var/fossil/www/a11y.js
└── everything else → Fossil :8081
└── serves /var/fossil/repos/*.fossil
Why this stack:
- Fossil handles version control, wikis, tickets, forum, chat — one binary, no dependencies
- Caddy provides the landing page and absorbs static file serving, keeping Fossil's URL space clean
- Cloudflare tunnel gives HTTPS without opening any inbound ports on the VPS — no firewall changes, no cert management, works behind NAT
- Port layout: Coolify/Traefik already owns port 8080, so Fossil uses 8081, Caddy uses 8090
1. Prerequisites
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget unzip build-essential libssl-dev zlib1g-dev
2. Build Fossil from Source
Fossil ships as a single C source amalgamation. The resulting binary has no runtime dependencies.
# Download the source tarball (check https://fossil-scm.org/home/uv/download.html for latest)
# The URL contains a hash — get the current link from the download page
wget https://fossil-scm.org/home/tarball/<hash>/fossil-src-<version>.tar.gz -P /tmp
cd /tmp
tar xzf fossil-src-<version>.tar.gz
cd fossil-src-<version>
# Configure with OpenSSL so HTTPS works for clone/push/pull
./configure --prefix=/usr/local --with-openssl=auto
# Build (single-threaded is fine, it's fast)
make
# Install to /usr/local/bin/fossil
sudo make install
# Verify
fossil version
Build dependencies explained:
build-essential— gcc, make, libc headerslibssl-dev— enables TLS forfossil clone/push/pullover HTTPSzlib1g-dev— compression support (bundled in Fossil but system lib is preferred)
3. Install Caddy
Caddy is installed from the official Cloudsmith apt repository. This gives automatic updates via apt upgrade.
# Add Caddy's apt repo
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# The system caddy service will start automatically — stop it
# We run our own service with a custom Caddyfile
sudo systemctl stop caddy
sudo systemctl disable caddy
# Install the binary to /usr/local/bin so it runs as our user
sudo cp /usr/bin/caddy /usr/local/bin/caddy
# Verify
caddy version
# → v2.9.1
4. Install cloudflared
cloudflared is installed from Cloudflare's official apt repository.
# Add Cloudflare's apt repo
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update
sudo apt install cloudflared
# Verify
cloudflared version
# → cloudflared version 2026.3.0
# Create a symlink so it's on the expected path
sudo ln -sf /usr/bin/cloudflared /usr/local/bin/cloudflared
5. Set Up the Cloudflare Tunnel
This creates a named tunnel that Cloudflare routes traffic through. You need a Cloudflare account with fossil.craftdesign.group in your DNS.
5a. Authenticate
cloudflared tunnel login
This opens a browser URL — visit it and authorise cloudflared for your Cloudflare account. It writes a certificate to ~/.cloudflared/cert.pem.
5b. Create the tunnel
cloudflared tunnel create fossil
This creates a tunnel named fossil and writes a credentials JSON file:
~/.cloudflared/<tunnel-id>.json
Note the tunnel ID printed — you'll need it in the config.
5c. Route DNS
cloudflared tunnel route dns fossil fossil.craftdesign.group
This creates a CNAME in Cloudflare DNS: fossil.craftdesign.group → <tunnel-id>.cfargotunnel.com.
The record is managed by cloudflared automatically.
5d. Write the config file
mkdir -p ~/.cloudflared
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: 45ea54d2-1b49-496b-bc35-c01c45d95297 # replace with your tunnel ID
credentials-file: /home/dmestas/.cloudflared/45ea54d2-1b49-496b-bc35-c01c45d95297.json
ingress:
- hostname: fossil.craftdesign.group
service: http://localhost:8090
- service: http_status:404
EOF
Ingress rules explained:
- All traffic for
fossil.craftdesign.groupis forwarded to Caddy on port 8090 - The final
http_status:404catch-all is required by cloudflared — any unmatched hostname returns 404
6. Directory Structure
sudo mkdir -p /var/fossil/repos /var/fossil/skin /var/fossil/www
sudo chown -R dmestas:dmestas /var/fossil
| Path | Purpose |
|---|---|
/var/fossil/repos/ |
All .fossil repository files |
/var/fossil/skin/ |
Shared skin source files — pushed to repos via apply.py |
/var/fossil/www/ |
Static assets served by Caddy: index.html, a11y.js |
/var/fossil/Caddyfile |
Caddy config |
7. Caddyfile
Caddy serves two roles: static files at / and /a11y.js, and reverse proxy to Fossil for everything else.
# /var/fossil/Caddyfile
:8090 {
@static path / /a11y.js
handle @static {
file_server {
root /var/fossil/www
}
}
reverse_proxy localhost:8081
}
Key points:
- Listens on port 8090 (not 80/443 — TLS is handled by Cloudflare at the edge)
@staticmatcher catches only/(the landing page) and/a11y.js(accessibility script)- Everything else passes through to Fossil at 8081
- No TLS config needed here — Cloudflare terminates TLS, sends plain HTTP to the tunnel
8. Systemd Services
Three services run as the dmestas user. They start in order on boot.
fossil.service
sudo tee /etc/systemd/system/fossil.service << 'EOF'
[Unit]
Description=Fossil SCM Server
After=network.target
[Service]
Type=simple
User=dmestas
ExecStart=/usr/local/bin/fossil server /var/fossil/repos \
--port 8081 \
--localhost \
--repolist \
--https \
--baseurl https://fossil.craftdesign.group \
--notfound /
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
Fossil flags explained:
| Flag | Why |
|------|-----|
| /var/fossil/repos | Directory mode — serves all *.fossil files in this directory automatically |
| --port 8081 | Local port (8080 is taken by Coolify/Traefik) |
| --localhost | Binds to 127.0.0.1 only — Fossil is never exposed directly to the internet |
| --repolist | Enables repo listing at / (Caddy overrides this with the custom landing page) |
| --https | Tells Fossil it's behind an HTTPS proxy — without this, Fossil generates http:// redirect loops |
| --baseurl | Forces all generated URLs to use the public domain |
| --notfound / | Redirects unknown root-level paths back to the repo listing instead of 404 |
caddy-fossil.service
sudo tee /etc/systemd/system/caddy-fossil.service << 'EOF'
[Unit]
Description=Caddy reverse proxy for Fossil SCM
After=network.target fossil.service
Requires=fossil.service
[Service]
Type=simple
User=dmestas
ExecStart=/usr/local/bin/caddy run --config /var/fossil/Caddyfile
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
cloudflared-fossil.service
sudo tee /etc/systemd/system/cloudflared-fossil.service << 'EOF'
[Unit]
Description=Cloudflare Tunnel for Fossil SCM
After=network-online.target fossil.service
Wants=network-online.target
Requires=fossil.service
[Service]
Type=simple
User=dmestas
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --config /home/dmestas/.cloudflared/config.yml run
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
Enable and start
sudo systemctl daemon-reload
sudo systemctl enable fossil caddy-fossil cloudflared-fossil
sudo systemctl start fossil caddy-fossil cloudflared-fossil
# Check all three are running
sudo systemctl status fossil caddy-fossil cloudflared-fossil
9. Create the First Repository
# Initialise on the server
fossil init /var/fossil/repos/myrepo.fossil
# Fossil picks up new .fossil files automatically — no restart needed
# Verify it's visible
curl -s http://localhost:8081/ | grep myrepo
Set admin credentials
Visit https://fossil.craftdesign.group/myrepo/setup_ulist to set the admin password for user dmestas.
Or do it via the CLI:
fossil user password dmestas <newpassword> -R /var/fossil/repos/myrepo.fossil
10. Apply the Skin
The skin lives in /var/fossil/skin/ and must be written into each repo's SQLite config table. The apply.py script does this for all repos at once.
python3 /var/fossil/skin/apply.py
Run this after:
- Creating a new repository
- Editing any skin file (
css.txt,header.txt,footer.txt)
How it works
Fossil stores skin parts (css, header, footer) as rows in the config table of each .fossil SQLite database. The script writes them directly:
import sqlite3, time, os, glob
REPOS = glob.glob("/var/fossil/repos/*.fossil")
SKIN_DIR = "/var/fossil/skin"
parts = {
"css": open(f"{SKIN_DIR}/css.txt").read(),
"header": open(f"{SKIN_DIR}/header.txt").read(),
"footer": open(f"{SKIN_DIR}/footer.txt").read(),
}
mtime = int(time.time())
for repo in REPOS:
con = sqlite3.connect(repo)
for name, value in parts.items():
con.execute("INSERT OR REPLACE INTO config(name,value,mtime) VALUES(?,?,?)",
(name, value, mtime))
con.commit()
con.close()
Note: Fossil's skin config keys are css, header, footer — no skin: prefix. This is different from what Fossil's web UI calls them internally.
11. The a11y.js Accessibility Layer
Fossil's built-in HTML has several accessibility gaps (missing ARIA landmarks, invisible graph lines on dark backgrounds, no keyboard nav on the hamburger menu). These are fixed via a JavaScript file served as a static asset by Caddy.
Why a static asset and not inline script:
Fossil's Content Security Policy is script-src 'self' 'nonce-<per-request>'. Inline scripts without the correct nonce are blocked. A file served from the same origin (/a11y.js) satisfies 'self' and loads cleanly.
The header template loads it with defer so it runs after Fossil's own inline scripts but before window.load:
<script defer src="/a11y.js"></script>
What it fixes:
isDarkflag — Fossil hardcodesfalsefor all custom skins; patched post-load so the timeline graph uses correct dark-mode colors- Hamburger menu ARIA —
aria-expanded,aria-controls, focus management, Escape-to-close - Timeline table —
aria-label,aria-hiddenon graph/filler cells, accessible time labels - Table headers —
scope="col"on all sortable tables - Submenu selects —
aria-labelderived from adjacent label text
12. Shared Login Across Repositories (Login Group)
Fossil supports a login group — a set of repos that share authentication state. Log in to any one repo and you're automatically authenticated on all others. Each repo still has its own user records, but credentials are synchronised via the group.
How it works
Each .fossil file stores two config keys:
login-group-name— human-readable group name (e.g.craftdesign)login-group-code— shared secret hex code that ties the repos together
Plus peer-repo-<name> entries pointing to the absolute paths of sibling repos on disk. When you log in, Fossil updates the session cookie for all peers in the group.
Current setup
All three repos are in the craftdesign login group:
/var/fossil/repos/edgesync.fossil/var/fossil/repos/go-libfossil.fossil/var/fossil/repos/fossil-skin.fossil
Adding a new repo to the group
fossil login-group join /var/fossil/repos/edgesync.fossil \
-R /var/fossil/repos/<newrepo>.fossil
Verify it joined:
fossil login-group -R /var/fossil/repos/<newrepo>.fossil
Setting up the group from scratch
If rebuilding, configure the group via the Python script (direct SQLite — the CLI join requires an open checkout):
import sqlite3, os, glob, hashlib, time
repos = glob.glob("/var/fossil/repos/*.fossil")
mtime = int(time.time())
group_code = hashlib.md5(("craftdesign" + str(mtime)).encode()).hexdigest()[:16]
for repo in repos:
con = sqlite3.connect(repo)
con.execute("INSERT OR REPLACE INTO config(name,value,mtime) VALUES('login-group-name','craftdesign',?)", (mtime,))
con.execute("INSERT OR REPLACE INTO config(name,value,mtime) VALUES('login-group-code',?,?)", (group_code, mtime))
for other in repos:
if other != repo:
key = "peer-repo-" + os.path.basename(other).replace(".fossil", "")
con.execute("INSERT OR REPLACE INTO config(name,value,mtime) VALUES(?,?,?)", (key, other, mtime))
con.commit()
con.close()
13. Adding a New Repository (Ongoing)
# 1. Create the repo file
fossil init /var/fossil/repos/<name>.fossil
# 2. Set admin password
fossil user password dmestas <password> -R /var/fossil/repos/<name>.fossil
# 3. Apply skin
python3 /var/fossil/skin/apply.py
# 4. Add a card to the landing page
# Edit /var/fossil/www/index.html — copy an existing .repo-card block
# 5. Commit the landing page change to the fossil-skin repo
cp /var/fossil/www/index.html /path/to/fossil-skin-checkout/www/
cd /path/to/fossil-skin-checkout
fossil commit -m "Add <name> repo card" --user dmestas
fossil push
From a local machine: ```bash fossil clone https://[email protected]/<name>/ <name>.fossil
or push an existing repo:
fossil remote add hub https://[email protected]/<name>/ fossil push hub ```
Service Management Reference
# Status of all three services
sudo systemctl status fossil caddy-fossil cloudflared-fossil
# Restart all
sudo systemctl restart fossil caddy-fossil cloudflared-fossil
# Tail logs
sudo journalctl -u fossil -f
sudo journalctl -u caddy-fossil -f
sudo journalctl -u cloudflared-fossil -f
# Reload Caddy config without restart (note: reload not supported — use restart)
sudo systemctl restart caddy-fossil
Troubleshooting
Fossil generates http:// redirect loops
Ensure --https flag is present on fossil server. Without it, Fossil sees a plain HTTP request from Caddy and tries to redirect to HTTPS, creating a loop.
Skin not showing up after edit
apply.py must be run after every skin edit — Fossil caches skin in the repo's SQLite config and doesn't read from the filesystem.
New repo not appearing
Fossil in directory mode picks up new .fossil files without restart, but it may take a few seconds. If it still doesn't appear, sudo systemctl restart fossil.
Cloudflare tunnel disconnects
If credentials expire: cloudflared tunnel login and re-run. The credentials JSON in ~/.cloudflared/ is valid for years under normal circumstances.
Port 8080 conflict
This VPS runs Coolify, whose Traefik proxy owns port 8080. Fossil uses 8081, Caddy uses 8090. If you're on a clean VPS without Coolify, you could use 8080/8081 instead — just update the Caddyfile and fossil.service accordingly.