Expose Forgejo publicly at forge.eblu.me (#278)
All checks were successful
Deploy Fly.io Proxy / deploy (push) Successful in 1m28s

## Summary

Expose Forgejo publicly at `forge.eblu.me` via the Fly.io reverse proxy — the first dynamic, authenticated public-facing service.

- **Forgejo hardening:** Domain changed to forge.eblu.me, SSH stays on forge.ops.eblu.me, reverse proxy trust headers configured, local registration locked to external-only (Authentik SSO)
- **Tailscale Ingress:** ExternalName Service + Ingress in tailscale-operator creates forge.tail8d86e.ts.net endpoint
- **Fly.io proxy:** nginx server block with rate-limited auth endpoints (3r/s), fail2ban with custom nginx-deny action, security headers, /swagger blocked, WebSocket support, 512m body limit
- **Authentik:** OAuth callback updated to forge.eblu.me
- **DNS/TLS:** CNAME record in Pulumi, cert in fly-setup
- **Rename:** ~29 files updated from forge.ops.eblu.me to forge.eblu.me (HTTPS refs only; SSH, container builds, and Caddy table kept as-is)

## Deployment Order

1. `mise run provision-indri -- --tags forgejo` (config changes)
2. Verify forge.ops.eblu.me still works
3. `argocd app set tailscale-operator --revision feature/forge-public && argocd app sync tailscale-operator`
4. Verify `curl https://forge.tail8d86e.ts.net`
5. `cd fly && fly deploy`
6. Verify pre-DNS: `curl -H "Host: forge.eblu.me" https://blumeops-proxy.fly.dev/`
7. `fly certs add forge.eblu.me -a blumeops-proxy`
8. `argocd app set authentik --revision feature/forge-public && argocd app sync authentik`
9. `mise run dns-preview && mise run dns-up`
10. Full verification (see below)
11. Rehearse `mise run fly-shutoff`
12. After merge: reset ArgoCD revisions to main, re-sync

## Verification Checklist

- [ ] forge.eblu.me loads, shows public repos
- [ ] forge.ops.eblu.me still works from tailnet
- [ ] SSH clone via forge.ops.eblu.me:2222 works
- [ ] HTTPS clone via forge.eblu.me works
- [ ] UI shows forge.eblu.me for HTTPS clone, forge.ops.eblu.me for SSH
- [ ] /swagger returns 403
- [ ] Rapid login attempts trigger 429 rate limit
- [ ] fail2ban bans after 5 failed logins in 10 minutes
- [ ] ArgoCD can still sync (SSH unaffected)
- [ ] `mise run fly-shutoff` stops all public traffic
- [ ] `mise run services-check` passes

Reviewed-on: #278
This commit is contained in:
Erich Blume 2026-03-03 08:40:41 -08:00
commit a87c997ee1
49 changed files with 340 additions and 128 deletions

View file

@ -6,7 +6,7 @@ metadata:
spec:
project: default
source:
repoURL: https://forge.ops.eblu.me/eblume/blumeops.git
repoURL: https://forge.eblu.me/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/forgejo-runner
destination:

View file

@ -120,7 +120,7 @@ data:
client_secret: !Env AUTHENTIK_FORGEJO_CLIENT_SECRET
redirect_uris:
- matching_mode: strict
url: https://forge.ops.eblu.me/user/oauth2/authentik/callback
url: https://forge.eblu.me/user/oauth2/authentik/callback
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
@ -138,7 +138,7 @@ data:
name: Forgejo
slug: forgejo
provider: !KeyOf forgejo-provider
meta_launch_url: https://forge.ops.eblu.me
meta_launch_url: https://forge.eblu.me
policy_engine_mode: any
# Policy binding — restrict Forgejo to admins group

View file

@ -27,7 +27,7 @@ spec:
name: http
env:
- name: CV_RELEASE_URL
value: "https://forge.ops.eblu.me/api/packages/eblume/generic/cv/v1.0.3/cv-v1.0.3.tar.gz"
value: "https://forge.eblu.me/api/packages/eblume/generic/cv/v1.0.3/cv-v1.0.3.tar.gz"
resources:
requests:
memory: "64Mi"

View file

@ -27,7 +27,7 @@ spec:
name: http
env:
- name: DOCS_RELEASE_URL
value: "https://forge.ops.eblu.me/eblume/blumeops/releases/download/v1.12.1/docs-v1.12.1.tar.gz"
value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.12.1/docs-v1.12.1.tar.gz"
resources:
requests:
memory: "64Mi"

View file

@ -25,7 +25,7 @@ spec:
- name: DOCKER_HOST
value: tcp://localhost:2375
- name: FORGEJO_URL
value: "https://forge.ops.eblu.me"
value: "https://forge.eblu.me"
- name: RUNNER_NAME
value: "k8s-runner"
- name: RUNNER_LABELS

View file

@ -1,11 +1,11 @@
- Host Services:
- Forgejo:
href: https://forge.ops.eblu.me
href: https://forge.eblu.me
icon: forgejo
description: Git forge
widget:
type: gitea
url: https://forge.ops.eblu.me
url: https://forge.eblu.me
key: "{{HOMEPAGE_VAR_FORGEJO_API_KEY}}"
- Registry:
href: https://registry.ops.eblu.me

View file

@ -73,7 +73,6 @@ kubectl logs -n tailscale -l app.kubernetes.io/name=operator
| `operator.yaml` | Operator deployment, CRDs, RBAC (secret removed) |
| `proxyclass.yaml` | ProxyClass with fully-qualified images |
| `dnsconfig.yaml` | DNSConfig for cluster-to-tailnet name resolution |
| `egress-forge.yaml` | Egress proxy for accessing forge on indri |
| `secret.yaml.tpl` | 1Password template for OAuth credentials (manual) |
| `README.md` | This file |
@ -86,5 +85,3 @@ kubectl logs -n tailscale -l app.kubernetes.io/name=operator
annotations:
tailscale.com/proxy-class: "default"
```
- The egress proxy for forge is **deprecated**. Forge is now accessible via Caddy at
`forge.ops.eblu.me` (HTTPS) and `forge.ops.eblu.me:2222` (SSH), which pods can reach directly.

View file

@ -1,23 +0,0 @@
# DEPRECATED: This egress proxy is no longer needed.
# Forge is now accessible via Caddy at forge.ops.eblu.me (HTTPS) and
# forge.ops.eblu.me:2222 (SSH), which pods can reach directly.
#
# Keeping this file for reference during migration. Remove once verified.
#
# Original purpose: Egress proxy to expose Forgejo (forge) to the cluster
# See: https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress
---
apiVersion: v1
kind: Service
metadata:
name: forge
namespace: tailscale
annotations:
tailscale.com/tailnet-fqdn: indri.tail8d86e.ts.net
tailscale.com/proxy-class: "default"
spec:
type: ExternalName
externalName: placeholder
ports:
- port: 3001
targetPort: 3001

View file

@ -0,0 +1,24 @@
---
# Manual Endpoints pointing to indri's Tailscale IP for the
# forge-external Service. Must match the Service name exactly.
#
# NOTE: ArgoCD excludes all Endpoints resources (resource.exclusions in
# argocd-cm) because they are normally auto-managed by the control plane.
# This manual Endpoints is the exception — it must be applied directly
# with kubectl, not via ArgoCD. It is listed in kustomization.yaml for
# documentation purposes only; ArgoCD will silently skip it.
#
# kubectl --context=minikube-indri apply -f endpoints-forge.yaml
#
apiVersion: v1
kind: Endpoints
metadata:
name: forge-external
namespace: tailscale
subsets:
- addresses:
- ip: 100.98.163.89
ports:
- name: http
port: 3001
protocol: TCP

View file

@ -0,0 +1,20 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: forge-tailscale
namespace: tailscale
annotations:
tailscale.com/proxy-class: "default"
tailscale.com/proxy-group: "ingress"
tailscale.com/tags: "tag:k8s,tag:flyio-target"
spec:
ingressClassName: tailscale
defaultBackend:
service:
name: forge-external
port:
number: 3001
tls:
- hosts:
- forge

View file

@ -7,5 +7,10 @@ namespace: tailscale
resources:
- ../tailscale-operator-base
- proxygroup-ingress.yaml
- egress-forge.yaml
- external-secret.yaml
- svc-forge-external.yaml
# endpoints-forge.yaml is NOT managed by ArgoCD — Endpoints are globally
# excluded in argocd-cm resource.exclusions (too noisy for auto-managed
# Endpoints). Apply manually:
# kubectl --context=minikube-indri apply -f endpoints-forge.yaml
- ingress-forge.yaml

View file

@ -0,0 +1,15 @@
---
# ClusterIP service for Forgejo on indri. Paired with endpoints-forge.yaml
# which provides the actual routing to indri's Tailscale IP.
# ExternalName services don't have a ClusterIP, which the Tailscale
# ingress operator requires.
apiVersion: v1
kind: Service
metadata:
name: forge-external
namespace: tailscale
spec:
ports:
- name: http
port: 3001
protocol: TCP