Module 5: Gitops With Argocd
3. Hands-on: Gitea in the cluster
Description
This lesson installs the first real piece of the mechanism lesson 2 explained: a complete Git server, running inside andes-cargo-cluster, with the Gitea project's official Helm chart. By the end, you're going to have a repository (andes-cargo/andes-cargo-k8s) with the nine manifests inherited from Modules 1-4, pushed with a real git push — the "notebook" the previous lesson's thermostat analogy needs before any thermostat exists to read it. Everything that follows actually ran, including a domain verification that had to be resolved before the first command: Gitea's documentation changed, at some point, the domain of its Helm chart.
Connection to the module
This lesson builds the "Git" half of lesson 2's mechanism. Lesson 4 builds the other half — ArgoCD — and lesson 6 connects both for the first time.
Step 0 — Verify the Helm chart's domain before installing anything
Before writing a single helm install, this guide verified something that can't be assumed from a language model's training or from an outdated guide: the exact domain where Gitea's official Helm chart lives. Open-source projects migrate domains over time — and Gitea did — so a command copied from an outdated source fails at the first step, with an error message that doesn't obviously say "wrong domain."
Gitea's official documentation, verified against docs.gitea.com/installation/install-on-kubernetes at the time this lesson was written, confirms the exact command:
helm repo add gitea-charts https://dl.gitea.com/charts/
dl.gitea.com, not dl.gitea.io. The project migrated its distribution domain from .io to .com — if you search for Gitea examples online, you're going to find both, and only one works today. Confirm it yourself before continuing:
helm repo add gitea-charts https://dl.gitea.com/charts/
helm repo update gitea-charts
helm search repo gitea-charts/gitea --versions
What to expect (literal, executed — the exact chart version is your variable value if Gitea published a newer version since this lesson was written):
"gitea-charts" has been added to your repositories
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "gitea-charts" chart repository
Update Complete. ⎈Happy Helming!⎈
NAME CHART VERSION APP VERSION DESCRIPTION
gitea-charts/gitea 12.7.0 1.27.0 Gitea Helm chart for Kubernetes
Chart 12.7.0, Gitea application 1.27.0 — the real versions the rest of this lesson installs.
Step 1 — This lab's values.yaml, and the reason for each line
A $0 lab doesn't need high availability or an external database: andes-cargo-cluster runs on your machine, not in production. This values.yaml explicitly disables PostgreSQL (the database the chart installs by default) and the Redis dependencies, leaving you with the simplest embedded database Gitea supports:
# gitea-values.yaml
gitea:
admin:
username: "andes-cargo"
password: "AndesCargo2026!"
email: "platform@andes-cargo.example"
config:
server:
OFFLINE_MODE: true
ROOT_URL: "http://gitea-http.gitea.svc.cluster.local:3000/"
database:
DB_TYPE: sqlite3
session:
PROVIDER: memory
cache:
ADAPTER: memory
queue:
TYPE: level
postgresql:
enabled: false
postgresql-ha:
enabled: false
persistence:
enabled: true
size: 1Gi
redis-cluster:
enabled: false
valkey-cluster:
enabled: false
gitea.admin— the chart creates this admin user on first boot; you're going to use it in Step 3 to create the repository via the API.ROOT_URL— the internal DNS name Gitea uses to build links inside its own UI.gitea-http.gitea.svc.cluster.local:3000is the standard Kubernetes Service name pattern (<service>.<namespace>.svc.cluster.local) — the same DNS name patternandes-cargo-status-api'sConfigMapalready uses for the LocalStack endpoint (Module 3), although, unlike Gitea, thatServicenever actually gets installed in this guide.database.DB_TYPE: sqlite3— the line that fixes this lesson's first real error (Step 2). Without it, the chart expects an external PostgreSQL database this lab never declared.session/cache/queue— all three, in memory or on local disk instead of an external service (Redis). The chart itself warns, at install time, that this configuration is "not recommended for production" — an honest and correct warning this lab accepts on purpose, for the same reason it accepted SQLite: $0, no external services, for a repository with nine YAML files, not for an organization with hundreds of developers.
Step 2 — The real error the first attempt left, and how it gets fixed
The first time this chart was installed to write this lesson, values.yaml didn't have the database.DB_TYPE: sqlite3 line — it only disabled PostgreSQL. The result, real, was this:
kubectl create namespace gitea
helm install gitea gitea-charts/gitea --namespace gitea -f gitea-values.yaml --version 12.7.0 --wait --timeout 4m
What happened (literal — the Pod entered a restart loop):
NAME READY STATUS RESTARTS AGE
gitea-64bdc78cdb-qb77n 0/1 Init:Error 4 (72s ago) 2m4s
kubectl logs -n gitea gitea-64bdc78cdb-qb77n -c configure-gitea --tail=60
==== BEGIN GITEA CONFIGURATION ====
2026/08/14 21:48:21 cmd/helper.go:58:initDB() [F] Database settings are missing from the configuration file: "/data/gitea/conf/app.ini".
Ensure you are running in the correct environment or set the correct configuration file with -c.
If this is the intended configuration file complete the [database] section.
Gitea migrate might fail due to database connection...This init-container will try again in a few seconds
The message is precise: disabling postgresql.enabled tells the chart "don't install a database," but it does not tell it which database to use instead — without an explicit database.DB_TYPE, the configure-gitea init-container has no [database] section to write to app.ini, and gitea migrate (the step that prepares the database schema) fails before it can boot. The fix is the line you already saw in Step 1: database.DB_TYPE: sqlite3 tells it, unambiguously, which engine to use.
Step 3 — Install the chart (with the corrected values.yaml)
kubectl create namespace gitea
helm install gitea gitea-charts/gitea \
--namespace gitea \
-f gitea-values.yaml \
--version 12.7.0 \
--wait --timeout 4m
What to expect (literal, executed):
NAME: gitea
LAST DEPLOYED: Fri Aug 14 15:49:05 2026
NAMESPACE: gitea
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
NOTES:
1. Get the application URL by running these commands:
echo "Visit http://127.0.0.1:3000 to use your application"
kubectl --namespace gitea port-forward svc/gitea-http 3000:3000
2. Review these warnings:
- Gitea uses 'memory' for caching which is not recommended for production use. See https://docs.gitea.com/next/admi...
- Gitea uses 'leveldb' for queue actions which is not recommended for production use. See https://docs.gitea.com/ne...
- Gitea uses 'memory' for sessions which is not recommended for production use. See https://docs.gitea.com/next/adm...
The three warnings are, exactly, the three lines you decided on purpose in Step 1 — the chart flags them because it's honest about its own production defaults, not because something is wrong for this lab.
kubectl get pods -n gitea
kubectl get svc -n gitea
What to expect (literal — the Pod name's hash suffix is your variable value):
NAME READY STATUS RESTARTS AGE
gitea-7578cc4c79-pljlz 1/1 Running 0 24s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
gitea-http ClusterIP None <none> 3000/TCP 24s
gitea-ssh ClusterIP None <none> 22/TCP 24s
gitea-http is a ClusterIP-type Service with no assigned IP (None) — it's a headless Service, a pattern the chart uses so a single Gitea Pod is reachable by its DNS name without going through normal ClusterIP load balancing. For the rest of this guide, all that matters is that gitea-http.gitea.svc.cluster.local:3000 responds — which is exactly what you confirm next.
Step 4 — Open a temporary tunnel and confirm the version via the API
kubectl port-forward -n gitea svc/gitea-http 3000:3000
In another terminal:
curl -s http://localhost:3000/api/v1/version -u andes-cargo:AndesCargo2026!
What to expect (literal, executed):
{"version":"1.27.0"}
1.27.0 — the same application version helm search repo confirmed in Step 0. This step's port-forward is temporary, just like the one you used in Modules 2-4: it lets you, from your machine, reach Gitea. ArgoCD, in lesson 4, is never going to need this tunnel — it talks to Gitea over internal DNS, inside the same cluster.
Step 5 — Create the andes-cargo/andes-cargo-k8s repository via the API
Gitea has a complete REST API, documented at /api/swagger on your own instance. Using it, instead of the web UI, leaves a reproducible command instead of a sequence of clicks:
curl -s -X POST http://localhost:3000/api/v1/user/repos \
-u andes-cargo:AndesCargo2026! \
-H "Content-Type: application/json" \
-d '{
"name": "andes-cargo-k8s",
"description": "GitOps source of truth for andes-cargo-status-api on Kubernetes",
"private": false,
"auto_init": false
}'
What to expect (literal, executed — the numeric id and timestamps are variable; full_name and clone_url are literal):
HTTP/1.1 201 Created
{"id":2,...,"full_name":"andes-cargo/andes-cargo-k8s","clone_url":"http://localhost:3000/andes-cargo/andes-cargo-k8s.git","default_branch":"main",...}
andes-cargo is, in this lab, both the admin user (Step 1) and the repository's "owner" in the URL — Gitea, like GitHub, uses the username as the first segment of owner/repo when the repository doesn't live inside a separate organization. The result (andes-cargo/andes-cargo-k8s) is identical to what you'd see if andes-cargo were an organization — the path the rest of this guide uses is this one, unchanged.
Note on this lab's credentials.
AndesCargo2026!is a lab password, visible in plain text in this file on purpose — the same honesty criterion this guide already applied withtest/testfor LocalStack since Module 3. None of this leaves your machine: Gitea runs insideandes-cargo-cluster, with no port exposed to the internet.
Step 6 — Push the nine inherited manifests with git push
The nine manifests already exist — they're, literally, the same files you applied by hand in Modules 1-4, with no content changes. This step just organizes them into a new Git repository:
mkdir andes-cargo-k8s && cd andes-cargo-k8s
cp /path/to/your/manifests/{namespace,deployment,service,configmap,secret,hpa,ingress,networkpolicy-default-deny,networkpolicy-allow-ingress-nginx}.yaml .
ls
configmap.yaml
deployment.yaml
hpa.yaml
ingress.yaml
namespace.yaml
networkpolicy-allow-ingress-nginx.yaml
networkpolicy-default-deny.yaml
secret.yaml
service.yaml
Nine files, zero new lines of application YAML — exactly the state Module 4 closed with.
git init
git config user.email "platform@andes-cargo.example"
git config user.name "Andes Cargo Platform"
git branch -m main
git add -A
git commit -m "Initial GitOps source: namespace, deployment, service, config, hpa, ingress, networkpolicy (inherited from M1-M4)"
git remote add origin http://andes-cargo:AndesCargo2026!@localhost:3000/andes-cargo/andes-cargo-k8s.git
git push -u origin main
What to expect (literal, executed):
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
Confirm, with a clean git clone from another folder, that the content actually arrived:
git clone http://andes-cargo:AndesCargo2026!@localhost:3000/andes-cargo/andes-cargo-k8s.git
Cloning into 'andes-cargo-k8s'...
git log --oneline
45b14f6 Initial GitOps source: namespace, deployment, service, config, hpa, ingress, networkpolicy (inherited from M1-M4)
45b14f6 is this commit's real hash — yours is going to be different, because a Git hash depends on each commit's exact content, author, and timestamp; no one, not even repeating the same commands, produces the same hash twice. What's literal is that a commit with this content exists on the remote repository's main branch — that's what you confirm with git log after cloning.
Visual summary: where each piece lives, today
andes-cargo-cluster (kind)
namespace: gitea namespace: andes-cargo
┌─────────────────────┐ ┌──────────────────────────┐
│ gitea (Pod) │ │ andes-cargo-status-api │
│ ├─ embedded SQLite │ │ (3 replicas, Ingress, │
│ └─ repo: │ │ NetworkPolicy — M1-M4) │
│ andes-cargo/ │ └──────────────────────────┘
│ andes-cargo-k8s │
│ (9 manifests, │ namespace: argocd
│ commit 45b14f6) │ ┌──────────────────────────┐
└─────────────────────┘ │ (doesn't exist yet — │
│ next lesson) │
└──────────────────────────┘
Gitea already has the complete "notebook." No "thermostat" exists yet to read it — that's exactly what lesson 4 installs.
Common mistakes
Copying dl.gitea.io from an outdated source instead of dl.gitea.com (the error this very lesson had to resolve before being written). What happens: helm repo add against the old domain can fail outright, or — worse — resolve to stale content if the old domain still responds with a frozen version of the chart index. How to spot it: if helm search repo gitea-charts/gitea --versions doesn't show the most recent version the current official documentation confirms. How to fix it: always use https://dl.gitea.com/charts/, verified against docs.gitea.com at the time this lesson was written — and, if you come back to this lab much later, re-confirm the domain against the official documentation before assuming it hasn't changed again.
Forgetting database.DB_TYPE: sqlite3 and assuming disabling PostgreSQL is enough (the real error from Step 2, documented with its literal log). What happens: Gitea's Pod enters Init:Error, restarting indefinitely. How to spot it: kubectl logs <pod> -c configure-gitea shows Database settings are missing from the configuration file. How to fix it: any chart that supports multiple database engines needs you to explicitly choose one when you disable the default (PostgreSQL, in this chart) — sqlite3 is the correct choice for a single-Pod lab with no high availability.
Using the clone_url URL with localhost from inside a Pod (a continuity mistake, relevant only starting in lesson 4). What happens: someone copies http://localhost:3000/andes-cargo/andes-cargo-k8s.git (the URL Gitea reports, meant for your machine via port-forward) and uses it as an ArgoCD Application's repoURL. How to spot it: ArgoCD reports a connection-refused error when trying to sync. How to fix it: localhost inside an argocd Pod doesn't point to Gitea — it points to the ArgoCD Pod itself. The correct repoURL for anything running inside the cluster is the Service's internal DNS name: http://gitea-http.gitea.svc.cluster.local:3000/andes-cargo/andes-cargo-k8s.git — lesson 6 uses exactly that.
Exercises
Exercise 1 — Diagnose Step 2's error without reading the solution. Before looking at Step 2 again, with only the message Database settings are missing from the configuration file: "/data/gitea/conf/app.ini", which line of values.yaml is missing, and why isn't "disabling PostgreSQL" enough by itself?
See solution
gitea.config.database.DB_TYPE: sqlite3 is missing. Disabling postgresql.enabled: false tells the chart "don't install a database managed by you" — but it doesn't fill in app.ini's [database] section with any alternative engine. Without that section, gitea migrate (the step that prepares the schema) doesn't know which database to run the migrations against, and fails before Gitea can boot.
Exercise 2 — Explain why localhost works for you but not for ArgoCD. In two or three sentences, explain to a colleague why http://localhost:3000/... works when you use it (with port-forward active) but is going to fail when ArgoCD, in lesson 6, tries to use the same URL.
See solution
A reasonable explanation: "kubectl port-forward creates a temporary tunnel between a port on my machine and Gitea's Service inside the cluster — 'localhost' in that context means 'my own machine,' and the tunnel does the rest of the work. ArgoCD, on the other hand, runs as a Pod inside the cluster itself: if I give it 'localhost,' it's going to interpret that as 'this same ArgoCD Pod,' not as my machine or as Gitea. It needs Gitea's Service's internal DNS name (gitea-http.gitea.svc.cluster.local), which resolves correctly from any Pod in the cluster, with no dependency on any tunnel."
Exercise 3 — Design a check that the commit arrived without relying on git log in the same folder. Without using the folder where you made the original commit, how would you confirm, with independent evidence, that the content actually reached the server?
See solution
Step 6 already does this: a fresh git clone, from a different folder, followed by git log --oneline on that freshly cloned copy. If the commit shows up there, the proof is independent of any local state that might be "lying" (for example, if the push had failed silently and the commit only existed in your local copy, the fresh clone wouldn't show it). An equally valid alternative: query Gitea's API directly (curl http://localhost:3000/api/v1/repos/andes-cargo/andes-cargo-k8s/commits), which queries the server's state without going through any local clone.
Summary and next step
This lesson installed the first real piece of the GitOps mechanism: Gitea, running inside andes-cargo-cluster with the official Helm chart (gitea-charts/gitea, version 12.7.0, application 1.27.0), verified against the current distribution domain (dl.gitea.com, not the .io from outdated sources). You created the andes-cargo/andes-cargo-k8s repository via the API, pushed the nine manifests inherited from Modules 1-4 with a real git push, and confirmed with an independent git clone that the content reached the server — not just your local copy. You also documented, with the literal log, this lesson's one real error: a values.yaml that disables PostgreSQL without declaring an alternative engine leaves Gitea with no database to boot against.
Before moving on you should be able to: explain why database.DB_TYPE: sqlite3 is necessary; distinguish when to use localhost (your machine, via port-forward) versus the internal DNS name (gitea-http.gitea.svc.cluster.local, for anything running inside the cluster); and confirm, with your own terminal, that andes-cargo/andes-cargo-k8s has the nine manifests on main.
Next lesson: hands-on, installing ArgoCD. There you install the "thermostat" that's going to read, every few seconds, the repository you just created — the second half of lesson 2's mechanism, and the last piece before lesson 6's first real sync.
Resources
- Gitea — Installation with Kubernetes — official documentation, the source for the
helm repo addcommand and thedl.gitea.comdomain verified in this lesson. - Gitea — Config Cheat Sheet, database section — complete reference for
DB_TYPEand the rest of the[database]options used ingitea-values.yaml. - Gitea — API Usage — documentation for the REST API used in Step 5 to create the repository without the web UI.
kubernetes-and-eks-in-production-guide(NIEVA), Module 4, lesson 8 — the exact origin of the nine manifests this repository versions, with no content changes.