Skip to content
fossil-skin Artifact [50bc9cd57e]
Login

Artifact [50bc9cd57e]

Artifact 50bc9cd57e73921242e90c5e833e448c2dfde368bc511e15843d82bf17998927:


# 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

```bash
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.

```bash
# 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 headers
- `libssl-dev` — enables TLS for `fossil clone`/`push`/`pull` over HTTPS
- `zlib1g-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`.

```bash
# 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.

```bash
# 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

```bash
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

```bash
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

```bash
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

```bash
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.group` is forwarded to Caddy on port 8090
- The final `http_status:404` catch-all is required by cloudflared — any unmatched hostname returns 404

---

## 6. Directory Structure

```bash
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)
- `@static` matcher 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

```bash
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

```bash
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

```bash
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

```bash
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

```bash
# 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:
```bash
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.

```bash
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:

```python
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`:

```html
<script defer src="/a11y.js"></script>
```

**What it fixes:**
- `isDark` flag — Fossil hardcodes `false` for 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-hidden` on graph/filler cells, accessible time labels
- Table headers — `scope="col"` on all sortable tables
- Submenu selects — `aria-label` derived 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

```bash
fossil login-group join /var/fossil/repos/edgesync.fossil \
  -R /var/fossil/repos/<newrepo>.fossil
```

Verify it joined:

```bash
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):

```python
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)

```bash
# 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

```bash
# 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.