// SELF-HOSTING

Deploying Freehold

A complete walkthrough for self-hosting Freehold on your own infrastructure: the Go API backend, the React web frontend, and the optional mobile PWA.

~15 min Go 1.24 · Node 20+ AGPLv3 © Scott Alan Miller · NTG · Quixotic Systems 2026

01Architecture at a glance

Freehold is a three-tier application that depends on three external services you provide: PostgreSQL, S3-compatible object storage, and an OIDC identity provider.

FRONTEND
React + Vite
Static site — Pages, Netlify, Nginx, S3
BACKEND API
Go 1.24 + Fiber
systemd service · validates JWT
DATABASE
PostgreSQL 15+
OBJECT STORE
S3 · B2 / Wasabi
pre-signed URLs The browser uploads & downloads file bytes directly to object storage — the API only handles auth & metadata.

File bytes bypass the API. Uploads and downloads use pre-signed S3 URLs, so the browser talks directly to object storage. Your bucket must allow CORS from the frontend origin.

Auth is OIDC + PKCE with a confidential client. The backend brokers the OIDC exchange; the frontend never holds the secret.

The frontend is configured at runtime. A /runtime-config endpoint serves config on serverless platforms; plain static hosts fall back to build-time VITE_* variables.

02Prerequisites

Tooling (build machine)

TOOLVERSIONUSED FOR
Go 1.24+ Building the backend + migration tool
Node + npm 20+ Building the frontend / mobile PWA
wrangler latest Deploying the frontend to Cloudflare Pages (optional)
ssh / scp Shipping the backend binary to a server

External services to provision first

PostgreSQL 15+

Uses uuid-ossp, pg_trgm, and a tsvector index. The role must allow CREATE EXTENSION.

S3-compatible storage

Backblaze B2, Wasabi, MinIO, or AWS S3 — endpoint, region, bucket, and keys.

OIDC provider

Authentik, Keycloak, or Auth0 as a confidential client.

03Provision the external services

3.1 — PostgreSQL

Create a database and a role. The role must be allowed to run CREATE EXTENSION — migrations install uuid-ossp and pg_trgm automatically.

psqlcopy
CREATE ROLE freehold WITH LOGIN PASSWORD 'change-me';
CREATE DATABASE freehold OWNER freehold;

# connection string
postgres://freehold:change-me@db-host:5432/freehold?sslmode=require

3.2 — Object storage (S3-compatible)

Create a bucket (e.g. freehold-files) and an access-key / secret-key pair scoped to it. Note the endpoint and region. Keep path-style addressing on for B2, Wasabi, and MinIO.

Backblaze B2 https://s3.us-west-000.backblazeb2.com · region us-west-000
Wasabi https://s3.us-east-1.wasabisys.com · region us-east-1
MinIO https://minio.example.com · any region · path-style

3.3 — OIDC provider (confidential client)

Register a confidential OAuth2/OIDC application. Record the issuer URL, client ID, and client secret.

GRANT TYPE
Authorization Code
PKCE
Enabled (S256)
CLIENT AUTH
On — client ID + secret
REDIRECT URI
https://<frontend>/callback
SCOPES
openid profile email
ISSUER
https://auth.example.com/.../freehold/

04Deploy the backend (Go API)

A single statically-linked binary plus a migrate helper and the migrations/ directory. Run it as a systemd service behind a TLS reverse proxy.

4.1 — Build

backend/copy
# API server (static Linux binary)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
  go build -o bin/freehold ./cmd/freehold

# Migration tool (needs the freehold_tools build tag)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
  go build -tags freehold_tools -o bin/migrate ./cmd/migrate

4.2 — Configure (.env)

The server fails fast if OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, STORAGE_ENDPOINT, or STORAGE_BUCKET are missing.

.envcopy
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
SERVER_BODY_LIMIT=524288000          # 500 MB

DATABASE_URL=postgres://freehold:***@db-host:5432/freehold?sslmode=require

OIDC_ISSUER_URL=https://auth.example.com/application/o/freehold/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=https://freehold.example.com/callback

STORAGE_ENDPOINT=https://s3.us-west-000.backblazeb2.com
STORAGE_REGION=us-west-000
STORAGE_BUCKET=freehold-files
STORAGE_ACCESS_KEY=your-access-key
STORAGE_SECRET_KEY=your-secret-key
STORAGE_USE_PATH_STYLE=true
STORAGE_PRESIGN_DURATION=15m

CORS_ALLOWED_ORIGINS=https://freehold.example.com

4.3 — Run migrations

Idempotent — applied versions are tracked in a schema_migrations table. Use ./migrate down to roll back one step.

migratecopy
DATABASE_URL='postgres://freehold:***@db-host:5432/freehold' \
  ./migrate up
4.4 — Configure bucket CORS

Because the browser talks directly to storage, the bucket must allow GET / PUT / HEAD from your frontend origin. Or sign in as admin and use Admin → Storage → Apply CORS to push a working policy automatically.

4.5 — systemd + 4.6 reverse proxy

freehold.servicecopy
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/freehold
ExecStart=/opt/freehold/freehold
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
nginxcopy
client_max_body_size 500m;

location / {
  proxy_pass http://127.0.0.1:8080;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto
    $scheme;
}

Keep the proxy body-size limit in sync with SERVER_BODY_LIMIT so large uploads aren't rejected. Health check: GET /api/v1/health → {"status":"ok"}

05Deploy the web frontend (React)

A static Vite build. The reference target is Cloudflare Pages (which also runs the bundled Functions); a netlify.toml is included, and any static host works.

build & deploycopy
cd frontend
npm install
npm run build        # → dist/

wrangler pages deploy dist/ \
  --project-name freehold
FOUR VARIABLES
VITE_API_URL
VITE_OIDC_AUTHORITY
VITE_OIDC_CLIENT_ID
VITE_OIDC_METADATA_URL
Runtime (recommended)

Set project env vars on Pages / Netlify. The /runtime-config Function serves them at load — change config without rebuilding.

Build time

Put values in frontend/.env before npm run build. The app falls back to these when /runtime-config is unavailable.

06Deploy the mobile PWA (optional)

A standalone, touch-optimized PWA on the same API. Build it, serve dist/ as a static SPA, and replace the placeholder icons in mobile/public/ before production.

mobile/copy
cd mobile
npm install
npm run build        # → dist/

07Environment variable reference

S3_* and STORAGE_* names are interchangeable; if both are set, S3_* wins.

VARIABLEREQDEFAULTDESCRIPTION
SERVER_PORT no 8080 Listen port
SERVER_BODY_LIMIT no 524288000 Max request body size (bytes) — 500 MB
DATABASE_URL yes localhost dev PostgreSQL connection string
OIDC_ISSUER_URL yes OIDC issuer URL
OIDC_CLIENT_ID yes OIDC client ID
OIDC_CLIENT_SECRET yes OIDC client secret (confidential)
OIDC_REDIRECT_URL no localhost:5173 Must match the IdP-registered redirect URI
STORAGE_ENDPOINT yes S3-compatible endpoint
STORAGE_REGION no us-east-1 Storage region
STORAGE_BUCKET yes Bucket name
STORAGE_ACCESS_KEY yes* Access key
STORAGE_SECRET_KEY yes* Secret key
STORAGE_USE_PATH_STYLE no true Path-style addressing (B2 / Wasabi / MinIO)
STORAGE_PRESIGN_DURATION no 15m Pre-signed URL lifetime
CORS_ALLOWED_ORIGINS no localhost:5173 Comma-separated allowed browser origins
FILE_COMPRESSION_ENABLED no true Enable stored-file compression

08One-command deploy (maintainer reference)

The repo ships scripts that automate the maintainer's own deployment. They are environment-specific — review and adapt hostnames, paths, and the SSH user before use.

./deploy-all.sh Builds + ships backend, then builds + deploys the frontend to Cloudflare Pages
backend/scripts/deploy.sh Cross-compiles, scps the binary + migrations, runs migrations, restarts systemd
backend/scripts/setup-service.sh One-time: installs and enables the systemd unit on the server
frontend/scripts/build-zip.sh Packages dist/ + Functions into a Pages-uploadable zip

09Verification checklist

curl https://<api>/api/v1/health returns {"status":"ok"}.
journalctl -u freehold shows database + storage initialised, no fatal errors.
Loading the frontend redirects to the OIDC provider and back to /callback cleanly.
After login you can create an org, upload a file, and download it again (exercises pre-signed URLs end-to-end).
Full-text search returns results (confirms the pg_trgm / tsvector migration applied).

10Troubleshooting

Server exits with "OIDC… is required" A required env var is unset in .env.
Login loops / "redirect_uri mismatch" OIDC_REDIRECT_URL ≠ the redirect URI registered at the IdP.
Browser CORS errors calling the API Frontend origin missing from CORS_ALLOWED_ORIGINS.
Uploads fail but the API is healthy Bucket CORS not configured — file bytes go browser → storage directly.
413 Request Entity Too Large Reverse-proxy client_max_body_size lower than SERVER_BODY_LIMIT.
Migrations fail on CREATE EXTENSION DB role lacks privilege; grant it or pre-create the extensions as superuser.
© 2026 NTG · Quixotic Systems · AGPLv3
Back to site → Top ↑