Quick Start - Admin Guide

Get started with Bovine Pages Server in minutes

Bovine Pages Server - Admin Setup Guide

Getting Bovine Pages Server running on your Traefik instance is straightforward. This guide walks you through the setup from scratch, building each piece as you go.

What You’re Building

Bovine Pages Server is a Traefik middleware plugin that turns your Forgejo or Gitea instance into a static hosting platform. Think GitHub Pages, but for your own infrastructure. Users push their sites to repositories, and the plugin serves them automatically with HTTPS.

Prerequisites

Before starting, you’ll need:

  • Traefik v2.0 or later with plugin support
  • A running Forgejo or Gitea instance
  • A domain for hosting pages (e.g., pages.example.com)
  • Docker and Docker Compose if you’re containerising
  • (Optional) A Redis instance for caching and custom domain management

1: Get Your DNS Sorted

First, set up DNS for your pages domain:

# Create these DNS records:
# Wildcard for user subdomains
*.pages.example.com  A  YOUR_TRAEFIK_IP

# Base domain
pages.example.com   A  YOUR_TRAEFIK_IP

If you want users to use custom domains, they’ll add their own DNS records pointing to your Traefik server.

2: Add the Plugin to Traefik

Open your Traefik static configuration (usually traefik.yml) and add the plugin:

experimental:
  plugins:
    pages-server:
      moduleName: github.com/sqcows/pages-server
      version: v0.1.4  # Check for the latest version

Restart Traefik to load the plugin.

3: Configure Let’s Encrypt

You’ll want automatic SSL certificates. Add this to your static config:

certificatesResolvers:
  # DNS Challenge for pages domains (wildcards)
  letsencrypt:
    acme:
      email: admin@example.com
      storage: /path/to/acme-dns.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"
  # HTTP Challenge for custom domains
  letsencrypt-http:
    acme:
      email: admin@example.com
      storage: /path/to/acme-http.json  # Persistent storage for certs
      httpChallenge:
        entryPoint: web

The plugin automatically handles ACME challenges for both your pages domain and custom domains, so no special configuration needed there.

4: Set Up the Middleware

Now create your dynamic configuration for the plugin. This goes in your dynamic config file (e.g., dynamic.yml):

http:
  middlewares:
    pages-server:
      plugin:
        pages-server:
          # Required settings
          pagesDomain: pages.example.com
          forgejoHost: https://git.example.com
          
          # Optional but recommended
          forgejoToken: your-forgejo-api-token  # Enables private repos
          
          # Error pages (optional)
          errorPagesRepo: system/error-pages  # Create this in Forgejo
          
          # Performance tuning
          cacheTTL: 300  # 5 minutes - adjust to taste

If you’re running Redis, add these settings:

http:
  middlewares:
    pages-server:
      plugin:
        pages-server:
          pagesDomain: pages.example.com
          forgejoHost: https://git.example.com
          
          # Redis configuration
          redisHost: localhost
          redisPort: 6379
          redisPassword: your-redis-password  # If using auth
          
          # Custom domain management via Redis
          traefikRedisRouterEnabled: true
          traefikRedisCertResolver: letsencrypt-http
          traefikRedisRootKey: traefik
          traefikRedisRouterTTL: 600

5: Configure the Routers

This is the most important bit. You need separate HTTP and HTTPS routers because ACME challenges come in on HTTP before certificates exist.

Understanding the Request Flow

Here’s how requests flow through the system:

flowchart TD
    Start([Incoming Request]) --> CheckPort{Which Port?}
    
    CheckPort -->|Port 80<br/>HTTP| CheckACME{Is it<br/>/.well-known/acme-challenge/*?}
    CheckPort -->|Port 443<br/>HTTPS| HTTPSRouter[HTTPS Router<br/>websecure entrypoint]
    
    CheckACME -->|Yes| ACMEPass[Pass to Traefik<br/>ACME Handler]
    CheckACME -->|No| Redirect301[301 Redirect<br/>to HTTPS]
    
    ACMEPass --> ACMEResponse[Return Challenge Response<br/>for Let's Encrypt]
    Redirect301 --> HTTPSRouter
    
    HTTPSRouter --> Middleware[pages-server<br/>middleware]
    
    Middleware --> CheckDomain{Domain Type?}
    
    CheckDomain -->|*.pages.example.com| PagesRouter[Pages Domain Router<br/>Priority: 10]
    CheckDomain -->|Custom Domain| CustomRouter[Custom Domain Router<br/>Priority: 1]
    
    PagesRouter --> Plugin[Plugin Handles Request]
    CustomRouter --> Plugin
    
    Plugin --> Cache{In Cache?}
    Cache -->|Yes| ReturnCached[Return Cached Content]
    Cache -->|No| Forgejo[Fetch from Forgejo API]
    
    Forgejo --> StoreCache[Store in Cache]
    StoreCache --> ReturnContent[Return Content]
    
    ACMEResponse --> End([Response Sent])
    ReturnCached --> End
    ReturnContent --> End
    
    style CheckACME fill:#ff9999
    style ACMEPass fill:#99ff99
    style Redirect301 fill:#ffcc99
    style Middleware fill:#9999ff
    style Plugin fill:#cc99ff

🎨 Edit this diagram

Key points from this flow:

  1. HTTP port (80) checks if it’s an ACME challenge first

    • ACME challenges bypass the redirect and go straight to Traefik’s handler
    • Everything else gets 301 redirected to HTTPS
  2. HTTPS port (443) routes directly through the middleware

    • No redirect logic needed
    • SSL certificates already in place
  3. Router priorities matter

    • Pages domain router (priority 10) matches first
    • Custom domain router (priority 1) is the catch-all

Now let’s configure these routers:

http:
  routers:
    # Pages domain - HTTPS
    pages-https:
      rule: "HostRegexp(`{subdomain:[a-z0-9-]+}.pages.example.com`)"
      priority: 10
      entryPoints:
        - websecure
      middlewares:
        - pages-server
      service: noop@internal
      tls:
        certResolver: letsencrypt-dns
        domains:
          - main: pages.example.com
            sans:
              - "*.pages.example.com"
    
    # Custom domains - HTTPS (catch-all with low priority)
    # Individual domain routers get created automatically in Redis
    pages-custom-https:
      rule: "HostRegexp(`{domain:.+}`)"
      priority: 1  # Lower than pages domain
      entryPoints:
        - websecure
      middlewares:
        - pages-server
      service: noop@internal
      # No TLS config - dynamic routers handle this
    
    # All domains - HTTP (handles ACME challenges + redirects)
    pages-http:
      rule: "HostRegexp(`{domain:.+}`)"
      priority: 1
      entryPoints:
        - web
      middlewares:
        - pages-server
      service: noop@internal

Critical: Don’t configure automatic HTTP→HTTPS redirect in your web entrypoint. The plugin handles this itself (while allowing ACME challenges through).

6: Enable Traefik Redis Provider (If Using Redis)

For custom domain SSL certificates to work automatically, enable Traefik’s Redis provider in your static config:

providers:
  redis:
    endpoints:
      - "localhost:6379"
    rootKey: traefik
    # username: ""  # If needed
    # password: ""  # If needed

When users register custom domains, the plugin writes router configs to Redis, and Traefik picks them up automatically. No manual SSL certificate requests needed.

7: Create System Repositories (Optional)

Create these repositories in your Forgejo instance for polish:

Error Pages Repository

# Create: system/error-pages
mkdir -p error-pages/public
cd error-pages

# Add .pages file
cat > .pages << EOF
enabled: true
EOF

# Create error pages
cat > public/404.html << EOF
<!DOCTYPE html>
<html>
<head><title>Page Not Found</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
  <h1>404 - Page Not Found</h1>
  <p>Sorry, the page you're looking for doesn't exist.</p>
</body>
</html>
EOF

# Similar files for 500.html, 502.html, 503.html

Docker Compose Example

If you’re running everything in containers, here’s a complete setup and I’ve included all the files that should get you up and running, be sure to create them all and replace any variables or example.com URLS. First lets create some directories:

Create working directories

mkdir -p data/dynamic logs valkey
touch data/acme-dns.json
touch data/acme-http.json

Traefik config files

Next you’ll need to create the traefik.yml file in data/traefik.yml:

api:
  dashboard: true
  debug: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

# Change the existing log section from ERROR to DEBUG
log:
  level: DEBUG  # Changed from ERROR
  filePath: "/logs/traefik.log"
  format: common

# Add access logs (new section)
accessLog:
  filePath: "/logs/access.log"
  format: common

serversTransport:
  insecureSkipVerify: true

providers:
  redis:
    endpoints:
      - "valkey:6379"
    rootKey: "traefik"
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedbydefault: false
  file:
    directory: /config/dynamic
    watch: true

experimental:
  plugins:
    pages-server:
      moduleName: github.com/sqcows/pages-server
      version: v0.1.4

certificatesResolvers:
  letsencrypt-dns:
    acme:
      email: admin@example.com
      storage: acme-dns.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"
  letsencrypt-http:
    acme:
      email: ric@squarecows.com
      storage: acme-http.json
      httpChallenge:
        entryPoint: web

Your data/dynamic/routers.yml should look like this:

  http:
    routers:
      # ------------------------------------------------------------------------
      # Pages Domain (*.pages.example.com) - HTTPS
      # ------------------------------------------------------------------------
      pages-https:
        rule: "HostRegexp(`^[a-z0-9-]+\\.pages\\.example\\.com$`)"
        priority: 10  # Higher priority than custom domains
        entryPoints:
          - websecure
        middlewares:
          - pages-server
        service: noop@internal
        tls:
          certResolver: letsencrypt-dns  # DNS challenge for wildcard
          domains:
            - main: "pages.example.com"
              sans:
                - "*.pages.example.com"

      # ------------------------------------------------------------------------
      # Custom Domains - HTTPS (catch-all)
      # ------------------------------------------------------------------------
      # This router catches all other domains. Individual domains get their own
      # routers dynamically created in Redis with SSL cert configuration.
      pages-custom-domains-https:
        rule: "HostRegexp(`^.+$`)"
        priority: 1  # Lower priority - catches everything else
        entryPoints:
          - websecure
        middlewares:
          - pages-server
        service: noop@internal
        # NO TLS certResolver here - individual routers in Redis handle it

      # ------------------------------------------------------------------------
      # HTTP Router (all domains)
      # ------------------------------------------------------------------------
      # Handles ACME challenges and HTTP→HTTPS redirects
      pages-http:
        rule: "HostRegexp(`^.+$`)"
        priority: 1
        entryPoints:
          - web
        middlewares:
          - pages-server
        service: noop@internal
        # No TLS config on HTTP router

Finally you’ll need data/dynamic/middlewares.yml:

    middlewares:
      pages-server:
        plugin:
          pages-server:
            # Required settings
            pagesDomain: pages.example.com
            forgejoHost: https://code.example.com

            # Optional: Forgejo API token (for private repos)
            # forgejoToken: your-token-here

            # Optional: Error pages repository
            # errorPagesRepo: system/error-pages

            # Optional: Custom domain settings
            enableCustomDomains: true

            # Redis caching (required for Traefik Redis router integration)
            redisHost: valkey
            redisPort: 6379
            # redisPassword: ""  # if needed
            cacheTTL: 600

            # Traefik Redis router integration (for automatic SSL certificates)
            traefikRedisRouterEnabled: true
            traefikRedisCertResolver: letsencrypt-http
            traefikRedisRootKey: traefik
            authCookieDuration: 3600  # Cookie validity in seconds (default: 3600 = 1 hour)
            authSecretKey: "BIG_LONG_SECRET_STRING"  # For HMAC cookie signing (recommended)

            # DNS verification
            enableCustomDomainDNSVerification: true

            # Redirects Vars
            maxRedirects: 25

The compose file and env

Now create a compose.yml file with the following content:

version: '3.8'

services:
  traefik:
    image: traefik:latest
    command:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik_proxy"
      - "traefik.http.routers.traefik.entrypoints=web"
      - "traefik.http.routers.traefik.rule=Host(`${HOSTNAME}`)"
      - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_PASSWORD}"
      - "traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.middlewares.sslheader.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.traefik.middlewares=traefik-https-redirect"
      - "traefik.http.routers.traefik-secure.entrypoints=websecure"
      - "traefik.http.routers.traefik-secure.rule=Host(`${HOSTNAME}`)"
      - "traefik.http.routers.traefik-secure.middlewares=traefik-auth"
      - "traefik.http.routers.traefik-secure.tls=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=letsencrypt-http"
      - "traefik.http.routers.traefik-secure.service=api@internal"
      # Define the port inside of the Docker service to use
      - "traefik.http.services.traefik-secure.loadbalancer.server.port=8080"
    ports:
      - "80:80"
      - "443:443"
    networks:
      - proxy
      - traefik-service
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./logs:/logs:rw
      - ./data/acme-dns.json:/acme.json:rw
      - ./data/acme-http.json:/acme-http.json:rw
      - ./data/traefik.yml:/traefik.yml:ro
      - ./data/dynamic:/config/dynamic:ro
    depends_on:
      - valkey

  valkey:
    image: valkey:latest
    networks:
      - traefik-service
    healthcheck:
      test:
        - CMD
        - valkey-cli
        - ping
    volumes:
      - ./valkey:/data

  #forgejo:
  #  image: codeberg.org/forgejo/forgejo:latest
  #  environment:
  #    - USER_UID=1000
  #    - USER_GID=1000
  #  volumes:
  #    - forgejo-data:/data
  # ... rest of Forgejo config

networks:
  proxy:
    external: false
  traefik-service:
    external: false

The supporting .env looks like this:

HOSTNAME=traefik.example.com
TRAEFIK_PASSWORD=admin:SOME_HASHED_PASSWORD
ACME_EMAIL=admin@example.com
CF_DNS_API_TOKEN=YOUR_KEY_TO_A_DNS_ZONE

You will of course need to provide your own forgejo config to your liking.

Running it all

Now you can run docker compose up -d and everything should start perfectly. Once it’s up and running you should be able to get to your traefik dashboard by visiting https://traefik.example.com.

Testing the Setup

Once everything’s running:

  1. Create a test repository in Forgejo with a public/ folder and .pages file
  2. Visit https://username.pages.example.com/repository
  3. Check that your site loads with a valid SSL certificate

Test Custom Domains

  1. Add custom_domain: test.yourdomain.com to your .pages file
  2. Point test.yourdomain.com DNS to your Traefik IP
  3. Visit your pages URL first to register the domain
  4. Visit https://test.yourdomain.com - SSL should work automatically