Skip to content

Forgejo on GKE with Cloudflare and Security Hardening

Forgejo on GKE with Cloudflare and Security Hardening

Section titled “Forgejo on GKE with Cloudflare and Security Hardening”

This setup turns a small GKE cluster into a self-hosted Git forge running Forgejo, exposed at https://forgejo.cornucopia.build, and fronted by Cloudflare.

The goal was not just to make it work, but to keep it inexpensive and defensible:

  1. Keep the cluster tiny.
  2. Keep the public endpoint stable.
  3. Require HTTPS.
  4. Restrict the origin to Cloudflare only.
  5. Require sign-in before browsing Forgejo content.

I also kept the infrastructure split from the earlier GPU experiments so the code-hosting stack can stand on its own.

The final shape is:

  1. One zonal GKE cluster.
  2. One forgejo Deployment.
  3. One ingress-nginx controller with a reserved static IP.
  4. One Cloudflare DNS A record for forgejo.cornucopia.build.
  5. cert-manager issuing a Let’s Encrypt certificate using Cloudflare DNS-01.
  6. Cloudflare proxying the hostname in front of the ingress.

The important part is that the origin is not directly exposed to the internet. The ingress controller only accepts traffic from Cloudflare IP ranges.

The deployment includes:

  1. Forgejo running in its own namespace.
  2. A small persistent volume for repository and application data.
  3. An HTTPS ingress using ingress-nginx.
  4. cert-manager with a Cloudflare DNS-01 ClusterIssuer.
  5. A reserved regional static IP, so the public address does not change every time the controller restarts.
  6. A site-owner account created from inside the container using the Forgejo CLI.

The cheapest practical version still has a few unavoidable costs, but each one was trimmed:

  1. The GKE node stays small.
  2. Forgejo requests only 100m CPU and 256Mi RAM, with a modest limit.
  3. The persistent volume is only 10Gi in the manifest.
  4. The ingress controller is small and runs as a single replica.
  5. The public IP is static, so there is no churn in DNS configuration.
  6. The site uses one load balancer instead of multiple public services.

In other words, this is not free, but it is intentionally lean.

These were the hardening steps we kept in place:

  1. HTTPS is enforced at the edge.
  2. Cloudflare serves as the public front door.
  3. The ingress controller only allows Cloudflare source IPs.
  4. Forgejo registration is disabled.
  5. Forgejo requires sign-in to view content.
  6. The site-owner account is created explicitly rather than relying on a permissive bootstrap flow.
  7. The origin IP is not meant to be a stable public access point for humans.

That gives three layers of defense:

  1. Cloudflare as the proxy and TLS boundary.
  2. GKE ingress restricted to Cloudflare source ranges.
  3. Forgejo itself requiring authentication.

The cluster credential step is separate from the app install:

Terminal window
gcloud container clusters get-credentials playground \
--zone northamerica-northeast1-c \
--project ckad-gke

Install the ingress controller:

Terminal window
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace

Reserve a static IP and bind the controller to it:

Terminal window
gcloud compute addresses create forgejo-ingress-ip \
--region northamerica-northeast1 \
--project ckad-gke

Update Cloudflare DNS to the reserved IP:

Terminal window
curl -X PUT "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records/<RECORD_ID>" \
-H "Authorization: Bearer <CLOUDFLARE_API_TOKEN>" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"forgejo.cornucopia.build","content":"34.19.194.216","ttl":120,"proxied":true}'

Forgejo provides a CLI inside the container image. The binary is forgejo, and the admin user command is:

Terminal window
forgejo admin user create --username <user> --password <password> --email <email> --admin --must-change-password=false

Because the container runs as root in the kubectl exec context, the command was executed as the non-root git user:

Terminal window
kubectl exec -n forgejo deploy/forgejo -- /bin/bash --noprofile --norc -lc \
"su -s /bin/bash git -c 'forgejo admin user create ...'"

The actual account created was a non-reserved site-owner username, not admin, because admin is reserved by Forgejo.

The current setup is intentionally conservative:

  1. The site is behind Cloudflare proxying.
  2. The origin only accepts Cloudflare IP ranges.
  3. The app does not allow open registration.
  4. The default view requires sign-in.
  5. The static IP is reserved, so the DNS record does not need to be changed repeatedly.

The most important maintenance command is checking the live public endpoint:

Terminal window
curl -I https://forgejo.cornucopia.build

And if you want to inspect the cluster state:

Terminal window
kubectl get pods -n forgejo
kubectl get svc -n ingress-nginx
kubectl get certificate -n forgejo

Cloudflare Access was the next hardening step, but it requires Cloudflare Zero Trust write access. The site is already protected without it, but Access would add another identity gate before Forgejo is reached.

If you add it later, the intended model is:

  1. Create a self-hosted Cloudflare Access application for forgejo.cornucopia.build.
  2. Require your identity provider or a tightly scoped allow policy.
  3. Keep the origin locked to Cloudflare only.

That would make the public hostname effectively inaccessible without Cloudflare-authenticated access.

The end result is a small, cheap, and reasonably locked-down Forgejo instance:

  1. GKE hosts the service.
  2. ingress-nginx provides the public entry point.
  3. Cloudflare handles DNS and proxying.
  4. cert-manager handles TLS.
  5. Forgejo requires authentication.
  6. The origin only trusts Cloudflare.

That is enough for a practical self-hosted code forge without turning the cluster into a security or cost liability.