Accessing the Forgejo Web UI

How to open the Forgejo web interface of a parallel-services environment, whether it's running locally under Docker Compose or on AWS (EKS). Forgejo is the worked example from the parallel-services-guide.md; the same approach applies to any HTTP service in the environment.

Locally (Docker Compose)

When you run the app's docker-compose services on your machine, Forgejo is reachable on whatever host port the compose file maps to its container port (3000). For example, a ports: ["3080:3000"] mapping gives:

http://localhost:3080

On AWS (parallel namespace)

On AWS the service runs in the isolated namespace wbsp-svc-‹tenant›-‹env› and is internal-only — there is no public URL, and a NetworkPolicy denies all ingress except the allow-listed apps. To reach it from your machine, port-forward the cluster service. kubectl port-forward tunnels through the Kubernetes API server, so it works regardless of the NetworkPolicy:

export AWS_PROFILE=<profile>
aws eks update-kubeconfig --name <cluster> --region <region>   # once

kubectl -n wbsp-svc-‹tenant›-‹env› port-forward svc/forgejo 3080:3000

Then open:

http://localhost:3080

Git access (clone / push / pull)

Forgejo serves git over HTTP (on its web port, 3000) and SSH (on port 22). You clone/push against whatever host and port you actually reach Forgejo on — the forwarded local port — regardless of the URL Forgejo displays in its UI (that reflects its configured ROOT_URL).

HTTP

# Locally (web port mapped to, e.g., 3080)
git clone http://localhost:3080/‹owner›/‹repo›.git

# On AWS — forward the web port first, then clone
kubectl -n wbsp-svc-‹tenant›-‹env› port-forward svc/forgejo 3080:3000
git clone http://localhost:3080/‹owner›/‹repo›.git

Pushing over HTTP requires credentials. Use a Forgejo username with a personal access token (or password) — either let git prompt, or inline it:

git clone http://‹user›:‹token›@localhost:3080/‹owner›/‹repo›.git

SSH

SSH needs the container's port 22 exposed. Locally that's whatever your compose file maps it to (e.g. "2222:22"); on AWS, port-forward it. The SSH user is always git, and your public key must be registered in Forgejo first (User Settings → SSH / GPG Keys).

# Locally (SSH mapped to, e.g., 2222)
git clone ssh://git@localhost:2222/‹owner›/‹repo›.git

# On AWS — forward the SSH port, then clone
kubectl -n wbsp-svc-‹tenant›-‹env› port-forward svc/forgejo 2222:22
git clone ssh://git@localhost:2222/‹owner›/‹repo›.git

Once cloned, the remote stays pointed at the forwarded address, so git push / git pull work as long as the relevant kubectl port-forward is running. You can forward the web and SSH ports at the same time (in separate shells).

Notes

  • First visit on AWS may show the install page. Don't complete it via the web form (see Troubleshooting). Instead configure Forgejo with FORGEJO__* environment variables in the compose service and set FORGEJO__security__INSTALL_LOCK=true so it skips the installer entirely.
  • Ephemeral storage. If the environment definition sets ephemeral_storage: true (used when the cluster has no persistent-volume provisioner), any state in the AWS Forgejo's /data volume is lost on pod restart. With env-based config this is fine — Forgejo regenerates its app.ini from the env on each start; persistent state lives in the database.

Troubleshooting

The database settings are invalid: dial tcp 127.0.0.1:5432: connect: connection refused

Forgejo is trying to reach Postgres on 127.0.0.1, but Postgres is a separate container/podlocalhost inside Forgejo is Forgejo itself. The DB connection is made server-side (from the Forgejo pod), so point it at the service name, not the address you reach the UI on:

  • Host: postgres:5432 (the compose service / Kubernetes Service name in the same network/namespace) — not 127.0.0.1.
  • Username / Password / Database: the values the compose postgres service provisions for Forgejo (e.g. forgejo / forgejo / forgejo).

Failed to save configuration: open /data/gitea/conf/app.ini: is a directory

The installer needs to write app.ini, but in a parallel-services environment a bind-mounted config file is mounted as a read-only ConfigMap, so Forgejo can't write it (the read-only mount surfaces as is a directory).

Don't use the web installer. Configure Forgejo via environment variables and remove the app.ini bind-mount, so Forgejo writes its own config into its writable /data volume at startup:

forgejo:
  environment:
    - FORGEJO__database__DB_TYPE=postgres
    - FORGEJO__database__HOST=postgres:5432
    - FORGEJO__database__NAME=forgejo
    - FORGEJO__database__USER=forgejo
    - FORGEJO__database__PASSWD=forgejo
    - FORGEJO__security__INSTALL_LOCK=true
    - FORGEJO__server__ROOT_URL=http://localhost:3080/
  volumes:
    - forgejo-data:/data
    # - ./docker/forgejo/app.ini:/data/gitea/conf/app.ini   ← remove this line

The Forgejo image's entrypoint runs environment-to-ini, writing those FORGEJO__section__key values into app.ini on the writable volume; this works both locally and in the parallel namespace. Re-apply and restart:

wbsp services up --config <env-def>.yaml --prune
kubectl -n wbsp-svc-‹tenant›-‹env› rollout restart deploy/forgejo

This reflects a parallel-services limitation: bind-mounted config files are mounted read-only, which suits files an app only reads but breaks apps that need to write their config file (like Forgejo's app.ini). Driving config through env vars avoids the mount entirely.

Webhooks out of Forgejo (three gates, not one)

Forgejo delivering a webhook to an app running in this cluster has to pass three separate checks. They fail in different ways and only the first one names itself clearly, which is why a webhook can look like a single-setting problem and be three.

  1. Forgejo's own allow-list. webhook.ALLOWED_HOST_LIST defaults to external, which permits public addresses and denies private ones. An in-cluster address is denied here, before any packet leaves — Forgejo says so in the delivery log ("webhook can only call allowed HTTP servers").
  2. Forgejo's namespace egress. The generated egress-dns-and-intra policy permits DNS and this namespace only, so the connection is dropped on the way out. This one is silent: it looks like a timeout.
  3. The destination namespace's ingress. An app namespace denies everything except traffic from traefik and its own gateway front. Also silent, also a timeout, and indistinguishable from (2) without checking both sides.

Fix each on its own terms:

# 1. Allow-list the exact destination host. Prefer the DNS name to a ClusterIP,
#    which changes when the Service is recreated. `external` is kept so existing
#    outbound webhooks to real internet endpoints still work.
kubectl -n <forgejo-ns> set env statefulset/forgejo \
  FORGEJO__webhook__ALLOWED_HOST_LIST='external,<svc>.<app-ns>.svc'

Forgejo reads this at startup, so setting it restarts the pod — which briefly interrupts git for every repository on the instance. Take a backup first (the wbsp-backup-forgejo skill) and pick your moment.

For (2) and (3), add sibling NetworkPolicies — one egress policy in the Forgejo namespace, one ingress policy in the app's namespace — rather than editing the existing ones. This matters:

  • egress-dns-and-intra, allow-required and default-deny-all are all generated. wbsp services up rewrites the first and a deploy rewrites the others, so an edit to any of them is silently reverted later, in exactly the invisible way the original problem failed.
  • NetworkPolicy rules are additive — a connection is allowed if any policy permits it — so a sibling widens the union without touching what a generator owns. This is the same pattern the platform itself uses for inter-app access (allow-inter-app-ingress / -egress).
  • --prune removes only deployments, statefulsets and services, so a sibling policy survives a reconcile.

Scope both to the one host and the one port. Every repository owner on the instance can register a webhook, so a wildcard such as *.svc would let any of them aim one at any service in the cluster and read the reply from the delivery log.

Live example: the allow-egress-website-webhook and allow-ingress-forgejo-webhook policies, added 2026-09-04 so pushes refresh wbsp.ai's variant data. Both carry a wbsp.ai/why annotation explaining themselves.

Confirmed end to end on 2026-09-05: a push to a repository whose hook points at the in-cluster address was delivered and recorded by the destination app. Until that delivery there was no proof the three changes were sufficient — a fourth gate would have looked identical from this side. Verify from the receiving end, not from Forgejo. Forgejo's delivery log shows a green tick for anything that got a response, and a probe from inside its pod proves only that gate 1 is open; the destination's own data is the only evidence all three are.

See also: Parallel services guide · Useful commands.