Co-authored-by: Blake Ridgway <blake@blakeridgway.com> Reviewed-on: #2
ridgwaysystems.org
Personal homelab site. Landing page, build log / blog, infrastructure overview, service status page, and an admin panel for managing content.
Why Go?
- Compiles to a single static binary. Easy to deploy on FreeBSD with no runtime dependencies.
- Standard library handles HTTP, HTML templates, and file serving without pulling in a framework.
- Fast startup, low memory footprint. Fits comfortably behind relayd on the SuperMicro 1U.
- The tool I know best for this kind of thing.
A static site generator (Hugo, etc.) was considered but ruled out because the admin panel requirement means a server-side process is needed anyway. Combining the site server and admin into one binary keeps the deployment simple.
Project Structure
.
├── cmd/server/main.go # Entry point
├── internal/
│ ├── blog/ # Post parsing and store (markdown + YAML frontmatter)
│ ├── feed/ # RSS 2.0 feed generation
│ ├── handler/ # HTTP handlers (public, admin, auth)
│ └── status/ # Service status JSON loading/saving
├── templates/
│ ├── base.html # Shared layout
│ ├── index.html # Landing page
│ ├── blog.html # Post list
│ ├── post.html # Single post
│ ├── infrastructure.html # Hardware and network diagram
│ ├── status.html # Service status page
│ ├── about.html # About / contact
│ └── admin/ # Admin panel templates
├── static/css/style.css # Stylesheet (dark mode, responsive)
├── content/posts/ # Blog posts as Markdown files with YAML frontmatter
├── data/status.json # Service status data (updated by cron or manually)
├── .env.example # Configuration reference
└── README.md
Building
go build -o website ./cmd/server
Requires Go 1.22+. All dependencies are managed with Go modules.
To download dependencies:
go mod tidy
Running
# Development — reloads templates on each request
DEV=1 PORT=8080 go run ./cmd/server
# With a .env file (requires a shell that sources it, or use envdir/dotenv)
export $(cat .env | grep -v '#' | xargs)
./website
Configuration
All configuration is via environment variables. See .env.example for the full list.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Listen port |
SITE_URL |
https://ridgwaysystems.org |
Public URL for RSS feed links |
CONTENT_DIR |
content |
Path to content directory |
DATA_DIR |
data |
Path to data directory (status.json) |
DEV |
0 |
Set 1 for template hot-reloading |
ADMIN_PASSWORD_HASH |
— | bcrypt hash of admin password |
SESSION_SECRET |
— | HMAC signing key for session cookies |
Generating an admin password hash
# Using htpasswd (from apache2-utils / httpd-tools)
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
# Or write a small Go snippet:
# import "golang.org/x/crypto/bcrypt"
# hash, _ := bcrypt.GenerateFromPassword([]byte("yourpassword"), 12)
# fmt.Println(string(hash))
Blog Post Format
Posts are Markdown files in content/posts/. Filenames become slugs unless overridden in
frontmatter.
---
title: "Post Title"
date: 2025-01-15
tags: [openbsd, homelab]
slug: optional-custom-slug
description: "Short description for meta tags and post listings."
draft: false
---
Post content here. Standard Markdown with GFM extensions (tables, strikethrough, etc.)
Syntax highlighting for fenced code blocks is automatic.
Status Page JSON Schema
data/status.json is read on each request to the /status page. A cron job or monitoring
script can write to this file to update service statuses.
{
"last_checked": "2025-02-10T12:00:00Z",
"services": [
{
"name": "Web (httpd)",
"description": "ridgwaysystems.org",
"url": "https://ridgwaysystems.org",
"status": "up",
"note": "Optional note shown on status page"
}
]
}
Valid status values: up, degraded, down, unknown.
Example cron update script
#!/bin/sh
# /usr/local/bin/check-services.sh
# Run every 5 minutes via cron. Updates data/status.json.
SITE_DIR=/var/www/ridgwaysystems
STATUS_FILE=$SITE_DIR/data/status.json
TMPFILE=$(mktemp)
check() {
name="$1"
url="$2"
if curl -sf --max-time 5 "$url" > /dev/null 2>&1; then
echo "up"
else
echo "down"
fi
}
WEB_STATUS=$(check "web" "https://ridgwaysystems.org")
GITEA_STATUS=$(check "gitea" "https://git.ridgwaysystems.org")
cat > "$TMPFILE" << EOF
{
"last_checked": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"services": [
{"name": "Web (httpd)", "description": "ridgwaysystems.org", "status": "$WEB_STATUS"},
{"name": "Gitea", "description": "git.ridgwaysystems.org", "status": "$GITEA_STATUS"}
]
}
EOF
mv "$TMPFILE" "$STATUS_FILE"
Deploying on FreeBSD
1. Build
On your control machine (or directly on the server):
GOOS=openbsd GOARCH=amd64 go build -o website ./cmd/server
Copy the binary and required files to the server:
scp website srv01:/usr/local/bin/website
rsync -av templates/ srv01:/var/www/ridgwaysystems/templates/
rsync -av static/ srv01:/var/www/ridgwaysystems/static/
rsync -av content/ srv01:/var/www/ridgwaysystems/content/
rsync -av data/ srv01:/var/www/ridgwaysystems/data/
2. rc.d service
Create /etc/rc.d/website:
#!/bin/ksh
daemon="/usr/local/bin/website"
daemon_user="www"
daemon_flags="-f"
. /etc/rc.d/rc.subr
rc_cmd $1
chmod +x /etc/rc.d/website
rcctl enable website
rcctl set website env "PORT=8080 SITE_URL=https://ridgwaysystems.org \
CONTENT_DIR=/var/www/ridgwaysystems/content \
DATA_DIR=/var/www/ridgwaysystems/data \
ADMIN_PASSWORD_HASH=<hash> SESSION_SECRET=<secret>"
rcctl start website
3. relayd configuration
Add to /etc/relayd.conf on fw01:
# ridgwaysystems.org
http protocol "ridgway-https" {
tls { keypair ridgwaysystems.org }
match request header set "X-Forwarded-For" value "$REMOTE_ADDR"
match request header set "X-Forwarded-Proto" value "https"
pass
}
relay "ridgway-web" {
listen on egress port 443 tls
protocol "ridgway-https"
forward to 10.0.10.10 port 8080 check http "/" code 200
}
4. httpd for Let's Encrypt ACME challenges (optional)
If using acme-client on fw01 for TLS certificates:
# /etc/httpd.conf on fw01
server "ridgwaysystems.org" {
listen on * port 80
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
}
location "*" {
block return 301 "https://ridgwaysystems.org$REQUEST_URI"
}
}
5. TLS certificates with acme-client
# /etc/acme-client.conf
authority letsencrypt {
api url "https://acme-v02.api.letsencrypt.org/directory"
account key "/etc/acme/letsencrypt-privkey.pem"
}
domain ridgwaysystems.org {
domain key "/etc/ssl/private/ridgwaysystems.org.key"
domain full chain certificate "/etc/ssl/ridgwaysystems.org.fullchain.pem"
sign with letsencrypt
}
Daily cron renewal in /etc/daily.local:
acme-client ridgwaysystems.org && rcctl reload relayd
Admin Panel
The admin panel is available at /admin. Log in with the password configured in
ADMIN_PASSWORD_HASH.
Features:
- Create, edit, and delete blog posts with Markdown editor and live preview
- Edit the service status JSON directly
- Session expires after 24 hours
The admin panel is intentionally minimal — one user, no roles, no audit log. It's for personal use on a self-hosted site.
No External Dependencies
The site loads nothing from external servers. No CDN fonts, no analytics, no third-party
scripts. Everything is self-contained in static/. This is intentional.