forgejo: open native self-signups, gated by Turnstile + email confirmation
All checks were successful
ci/woodpecker/push/default Pipeline was successful

Viktor wants Forgejo open for anyone to sign up, but without bot/spam
account floods. Flip the deployment from OAuth-only registration
(ALLOW_ONLY_EXTERNAL_REGISTRATION=true) to allowing native local
sign-up, and add two bot gates on the registration form:

  - Cloudflare Turnstile captcha (CAPTCHA_TYPE=cfturnstile). The widget
    is managed in Terraform (turnstile.tf) via the CF Global API key, so
    the sitekey/secret are IaC, not a dashboard artifact.
  - Mandatory email confirmation (REGISTER_EMAIL_CONFIRM=true). Wire the
    Forgejo mailer to the cluster mailserver as noreply@viktorbarzin.me
    (mail.viktorbarzin.me:587 STARTTLS), reusing the same Vault-sourced
    credential Authentik uses (email-secret.tf ESO -> secret/authentik
    smtp_password).

Existing Authentik OAuth2 login is unchanged (additive). Deployment env
appended (not inserted) so the diff stays purely additive; a reloader
annotation rolls the pod on secret rotation.

Verified live: signup page renders the Turnstile widget, mailer delivers
a test message end-to-end, Forgejo healthy, plan-to-zero after apply.

Runbook: docs/runbooks/forgejo-open-signups.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-19 16:05:07 +00:00
parent 21dbd79ae4
commit 963e4fcdde
5 changed files with 292 additions and 3 deletions

View file

@ -0,0 +1,40 @@
# SMTP password for Forgejo's open-signup email-confirmation + notification
# mail (main.tf: FORGEJO__service__REGISTER_EMAIL_CONFIRM + [mailer]). Synced
# from Vault secret/authentik -> smtp_password into the forgejo namespace as the
# `forgejo-email` Secret (key PASSWD), referenced by FORGEJO__mailer__PASSWD.
# Reuses the same noreply@viktorbarzin.me mailserver SASL account Authentik uses
# (stacks/authentik/email-secret.tf) one credential, one rotation point. The
# reloader annotation rolls the Forgejo pod if the password is ever rotated.
resource "kubernetes_manifest" "forgejo_email_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "forgejo-email"
namespace = "forgejo"
}
spec = {
refreshInterval = "1h"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "forgejo-email"
template = {
metadata = {
annotations = {
"reloader.stakater.com/match" = "true"
}
}
}
}
data = [
{
secretKey = "PASSWD"
remoteRef = { key = "authentik", property = "smtp_password" }
},
]
}
}
}

View file

@ -92,8 +92,16 @@ resource "kubernetes_deployment" "forgejo" {
# from 11.0.14 1.18 on 2026-05-24 (same bug as memory id=1933).
# TF owns the tag now; bump it manually here when upgrading.
"keel.sh/policy" = "never"
# Roll the pod when the signup secrets (forgejo-email password from Vault,
# forgejo-turnstile secret) change env vars are read at boot, not
# hot-reloaded. Stakater Reloader watches all referenced secrets/CMs.
"reloader.stakater.com/auto" = "true"
}
}
# The forgejo-email Secret is materialised by the External Secrets operator
# from the forgejo-email ExternalSecret (email-secret.tf); ensure the CR
# exists before this deployment references it on a from-scratch apply.
depends_on = [kubernetes_manifest.forgejo_email_secret]
spec {
replicas = 1
strategy {
@ -142,14 +150,21 @@ resource "kubernetes_deployment" "forgejo" {
name = "FORGEJO__server__ROOT_URL"
value = "https://forgejo.viktorbarzin.me"
}
# Disable local registration only allow OAuth2 (Authentik)
# Open self-service registration. Native local sign-up is allowed
# (ALLOW_ONLY_EXTERNAL_REGISTRATION=false) alongside the existing
# Authentik OAuth2 login. Bot abuse is gated by Cloudflare Turnstile
# (ENABLE_CAPTCHA below) + mandatory email confirmation
# (REGISTER_EMAIL_CONFIRM below). To re-close signups: set
# DISABLE_REGISTRATION=true, or flip ALLOW_ONLY_EXTERNAL_REGISTRATION
# back to "true" for OAuth-only. Runbook:
# docs/runbooks/forgejo-open-signups.md
env {
name = "FORGEJO__service__DISABLE_REGISTRATION"
value = "false"
}
env {
name = "FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION"
value = "true"
value = "false"
}
env {
name = "FORGEJO__openid__ENABLE_OPENID_SIGNIN"
@ -190,6 +205,81 @@ resource "kubernetes_deployment" "forgejo" {
name = "FORGEJO__repository__DISABLE_DOWNLOAD_SOURCE_ARCHIVES"
value = "true"
}
# --- Open-signup bot prevention + mailer (appended so the diff vs the
# pre-signup deployment stays purely additive). ---
# Cloudflare Turnstile captcha on the registration form (widget
# managed in turnstile.tf). Sitekey is public (rendered in the page);
# the secret is injected from the forgejo-turnstile Secret. Guards
# registration only not every login (REQUIRE_CAPTCHA_FOR_LOGIN left
# at the default false).
env {
name = "FORGEJO__service__ENABLE_CAPTCHA"
value = "true"
}
env {
name = "FORGEJO__service__CAPTCHA_TYPE"
value = "cfturnstile"
}
env {
name = "FORGEJO__service__CF_TURNSTILE_SITEKEY"
value = cloudflare_turnstile_widget.forgejo_signup.id
}
env {
name = "FORGEJO__service__CF_TURNSTILE_SECRET"
value_from {
secret_key_ref {
name = kubernetes_secret.forgejo_turnstile.metadata[0].name
key = "cf-turnstile-secret"
}
}
}
# Mandatory email confirmation: new accounts stay inactive until the
# user clicks an emailed activation link (kills throwaway-email bots).
env {
name = "FORGEJO__service__REGISTER_EMAIL_CONFIRM"
value = "true"
}
# Mailer: reuse the noreply@viktorbarzin.me mailserver SASL account
# (same as Authentik). MUST use the public host mail.viktorbarzin.me,
# NOT mailserver.mailserver.svc the mailserver serves the
# *.viktorbarzin.me wildcard cert which does not cover the svc DNS
# name, so STARTTLS verification would fail. mail.viktorbarzin.me
# resolves in-cluster (10.0.20.1) and matches the cert. Password from
# the forgejo-email ESO Secret (Vault secret/authentik ->
# smtp_password; see email-secret.tf).
env {
name = "FORGEJO__mailer__ENABLED"
value = "true"
}
env {
name = "FORGEJO__mailer__PROTOCOL"
value = "smtp+starttls"
}
env {
name = "FORGEJO__mailer__SMTP_ADDR"
value = "mail.viktorbarzin.me"
}
env {
name = "FORGEJO__mailer__SMTP_PORT"
value = "587"
}
env {
name = "FORGEJO__mailer__FROM"
value = "Forgejo <noreply@viktorbarzin.me>"
}
env {
name = "FORGEJO__mailer__USER"
value = "noreply@viktorbarzin.me"
}
env {
name = "FORGEJO__mailer__PASSWD"
value_from {
secret_key_ref {
name = "forgejo-email"
key = "PASSWD"
}
}
}
volume_mount {
name = "data"
mount_path = "/data"

View file

@ -0,0 +1,31 @@
# Cloudflare Turnstile widget guarding the open Forgejo signup form
# (main.tf: FORGEJO__service__ENABLE_CAPTCHA + CAPTCHA_TYPE=cfturnstile).
# Managed here so the sitekey/secret are IaC rather than a dashboard artifact.
# The CF Global API Key (cloudflare_provider.tf) has account-wide access, so it
# can manage Turnstile. The widget secret is sensitive and lands in TF state
# (Tier-1 PG, encrypted at rest) same trust level as the API key already in
# state. Forgejo is non-proxied, but Turnstile is a client-side JS widget served
# from challenges.cloudflare.com, so proxy status is irrelevant.
data "cloudflare_accounts" "main" {}
resource "cloudflare_turnstile_widget" "forgejo_signup" {
account_id = data.cloudflare_accounts.main.accounts[0].id
name = "forgejo-signup"
domains = ["forgejo.viktorbarzin.me"]
# "managed" = Cloudflare adaptively decides whether to show an interactive
# challenge; lowest friction for real users, strong against bots.
mode = "managed"
}
# Turnstile secret -> K8s Secret consumed by the Forgejo deployment via
# secret_key_ref (FORGEJO__service__CF_TURNSTILE_SECRET). The sitekey is public
# and passed as a plain env value in main.tf.
resource "kubernetes_secret" "forgejo_turnstile" {
metadata {
name = "forgejo-turnstile"
namespace = kubernetes_namespace.forgejo.metadata[0].name
}
data = {
"cf-turnstile-secret" = cloudflare_turnstile_widget.forgejo_signup.secret
}
}