Skip to content
fossil-skin Artifact [9ca17ef605]
Login

Artifact [9ca17ef605]

Artifact 9ca17ef6057bdf9196d77845ea6629444f6a493a66962392d6f96c23e3f5cd5c:


# 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)
wget https://fossil-scm.org/home/uv/fossil-src-2.28.tar.gz -P /tmp
cd /tmp
tar xzf fossil-src-2.28.tar.gz
cd fossil-src-2.28

# 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
# → This is fossil version 2.28 [52445a27f1] 2026-03-11 11:31:46 UTC
```

**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. 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.