Skip to content
fossil-skin fossil.craftdesign.group — Full Setup Guide
Login

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:


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:


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:


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:


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:

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:


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:

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:

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]// .fossil

or push an existing repo:

fossil remote add hub https://[email protected]// fossil push hub ```


14. Git Mirrors (go-libfossil, edgesync)

mirror/git-mirror.sh keeps a repo as a full-history mirror of a GitHub repo, using git fast-export | fossil import --git --incremental with marks files so each run only imports new commits. State lives in /var/fossil/mirror/<name>/ (a bare clone plus git.marks and fossil.marks); replaced repo files are kept in /var/fossil/mirror/retired/.

# first time (keeps the old repo's wiki pages, retires the old .fossil)
/var/fossil/mirror/git-mirror.sh init <name> <git-url> "<description>"
fossil login-group join /var/fossil/repos/zit.fossil -R /var/fossil/repos/<name>.fossil
python3 /var/fossil/skin/apply.py

# afterwards, hourly from cron (17 * * * *), or by hand:
/var/fossil/mirror/git-mirror.sh sync
tail /var/fossil/mirror/sync.log

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.