Quick Start - Admin Guide
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
Using Redis (Recommended for Production)
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
Key points from this flow:
-
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
-
HTTPS port (443) routes directly through the middleware
- No redirect logic needed
- SSL certificates already in place
-
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:
- Create a test repository in Forgejo with a
public/folder and.pagesfile - Visit
https://username.pages.example.com/repository - Check that your site loads with a valid SSL certificate
Test Custom Domains
- Add
custom_domain: test.yourdomain.comto your.pagesfile - Point
test.yourdomain.comDNS to your Traefik IP - Visit your pages URL first to register the domain
- Visit
https://test.yourdomain.com- SSL should work automatically