--- name: gitea-self-hosted description: Deploy and manage Gitea (self-hosted Git service) with Docker, nginx, Let's Encrypt, and VPS reverse proxy. version: 1.1.0 author: ray platforms: [linux] created_by: "agent" category: self-hosting metadata: hermes: tags: [gitea, git, self-hosted, docker, nginx, reverse-proxy, ssl] --- # Gitea Self-Hosted Git Service Deploy Gitea — a lightweight, self-hosted Git service — via Docker Compose with nginx reverse proxy, Let's Encrypt SSL, and optional VPS edge proxy through Tailscale. ## When to use - User wants a private, self-hosted Git forge (no GitHub dependency) - User has a Docker-capable home server with storage for repos - User wants git history and rollback for their projects (dev code, Docker configs, automation scripts) ## Architecture ``` Browser → VPS (nginx SSL, tailscale) → Home nginx (SSL) → Gitea (Docker:3000) ``` Gitea runs in a single Docker container with SQLite backend. Data persists on storage drive. ## Docker Compose ### docker-compose.yml ```yaml services: gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped volumes: - ./data:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - "127.0.0.1:3000:3000" environment: - USER_UID=1000 - USER_GID=1000 - GITEA__database__DB_TYPE=sqlite3 - GITEA__server__DOMAIN=gitea.graj-media.com - GITEA__server__SSH_DOMAIN=gitea.graj-media.com - GITEA__server__HTTP_PORT=3000 - GITEA__server__ROOT_URL=https://gitea.graj-media.com - GITEA__server__DISABLE_SSH=true - GITEA__server__LFS_START_SERVER=true networks: - gitea-net networks: gitea-net: driver: bridge ``` **Critical: Do NOT map port 22 for SSH.** Gitea's internal SSH server conflicts with the container's system SSH daemon. Set `DISABLE_SSH=true` and `START_SSH_SERVER=false` to prevent crash loops. If SSH git protocol is needed, forward a different host port (e.g., `"127.0.0.1:3022:22"`) and set `SSH_PORT=3022`. ## Initial setup (skip the web installer) Gitea's Docker entrypoint rewrites `app.ini` from environment variables on every restart, so the web install page is unreliable with env-var-based config. Set up via CLI instead: ```bash # 1. Stop the running container docker compose -f /path/to/docker-compose.yml stop # 2. Generate secrets and write config cat > /path/to/data/gitea/conf/app.ini << 'INI' APP_NAME = Ray's Git RUN_MODE = prod [server] APP_DATA_PATH = /data/gitea DOMAIN = gitea.graj-media.com SSH_DOMAIN = gitea.graj-media.com HTTP_PORT = 3000 ROOT_URL = https://gitea.graj-media.com DISABLE_SSH = true LFS_START_SERVER = true [database] PATH = /data/gitea/gitea.db DB_TYPE = sqlite3 [security] INSTALL_LOCK = true SECRET_KEY = [oauth2] JWT_SECRET = [git] LFS_JWT_SECRET = [service] DISABLE_REGISTRATION = false REQUIRE_SIGNIN_VIEW = false INI # 3. Run database migration (as git user, UID 1000) docker run --rm \ -v /path/to/data:/data \ --user 1000:1000 \ gitea/gitea:latest \ gitea migrate # 4. Create admin user docker run --rm \ -v /path/to/data:/data \ --user 1000:1000 \ gitea/gitea:latest \ gitea admin user create --username ray --password "" --email ray@example.com --admin # 5. Start the container docker compose -f /path/to/docker-compose.yml up -d ``` ## API Token Workflow (Post-Deploy Automation) After Gitea is running with an admin user, generate an API token for automation (repo creation, user management, etc.): ```bash # Generate an API token via CLI (scoped to all) TOKEN=$(docker exec -u git gitea gitea admin user generate-access-token \ --username ray --token-name "automation" --scopes "all" 2>&1 | grep -v "^$") echo "Token: $TOKEN" ``` ### Create repos via the API ```bash TOKEN="" BASE="http://127.0.0.1:3000/api/v1" curl -s -X POST "$BASE/user/repos" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"my-repo","private":false,"auto_init":false}' ``` All repos start empty. Push code after creation (see below). ### Push code to a new repo When pushing over HTTP with credentials in the URL, URL-encode special characters: ```bash # Example: password "4W#UxJ^acTrdPT" → encode # as %23 and ^ as %5E # 4W%23UxJ%5EacTrdPT cd /path/to/project git init git add -A git commit -m "initial commit" git remote add origin http://ray:4W%23UxJ%5EacTrdPT@127.0.0.1:3000/ray/my-repo.git git branch -m master main # Gitea defaults to 'main' branch git push -u origin main ``` **Pitfall — default branch is `master`, not `main`**: `git init` creates a `master` branch by default, but Gitea repos default to `main`. Rename before pushing: `git branch -m master main`. **Pitfall — `must_change_password` blocks API access**: When the admin user was created via `gitea admin user create`, the user record may have `must_change_password=1` set. This causes ALL API calls (even with a valid token) to return: ```json {"message":"You must change your password. Change it at: ..."} ``` Fix — clear the flag directly in SQLite (no restart needed): ```bash docker exec gitea sqlite3 /data/gitea/gitea.db \ "UPDATE user SET must_change_password=0 WHERE name='ray';" ``` Then retry the API call. ## Home server nginx Gitea needs nginx reverse proxy on the home server: ```nginx server { listen 443 ssl; server_name gitea.graj-media.com; ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; client_max_body_size 512M; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ## VPS reverse proxy (when using a VPS edge) If the home server is behind a VPS (Tailscale tunnel), add a server block on the VPS: ```nginx server { server_name gitea.graj-media.com; client_max_body_size 512M; location / { proxy_pass https://100.93.253.36:443; # home server Tailscale IP proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_ssl_verify off; proxy_read_timeout 86400; proxy_buffering off; } listen 443 ssl; ssl_certificate /etc/letsencrypt/live/graj-media.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/graj-media.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; } ``` Then expand the VPS cert to include the new subdomain. See `vps-reverse-proxy` skill Step 6 for the incremental `--expand` pattern. ## Pitfalls - **Docker entrypoint overrides app.ini on restart**: Environment variables (in `GITEA__section__key` format) take precedence. If you set `DISABLE_SSH=false` via env var, Gitea will try to bind port 22 inside the container, conflicting with the system SSH daemon. The container enters a crash loop: "bind: address already in use". Fix: set `DISABLE_SSH=true` AND `START_SSH_SERVER=false` via env vars in docker-compose.yml. - **`gitea` CLI refusals as root**: Gitea refuses to run as root. Use `--user 1000:1000` with ephemeral containers, or `docker exec -u git gitea gitea ` on running containers. - **app.ini provisioning order**: If you write app.ini manually (with `INSTALL_LOCK=true`) but the database hasn't been initialized yet, Gitea crashes at startup. Always run `gitea migrate` before starting with `INSTALL_LOCK=true`. Conversely, if `INSTALL_LOCK=false`, the entrypoint regenerates app.ini from env vars and ignores your manual edits. - **SSH port mapping inside container**: The `ports:` directive `"127.0.0.1:3022:22"` maps host port 3022 to container port 22. But if DISABLE_SSH=false, Gitea's built-in SSH server competes with the container's `sshd` for port 22. Preferred: set `DISABLE_SSH=true` and use HTTPS cloning only. - **Logs showing "Unable to GetListener: bind: address already in use"** means SSH port conflict. Either disable SSH or use a non-conflicting internal port via `SSH_LISTEN_PORT=`. - **CSRF token on install page**: When using the web installer (not CLI), the form submission POST may hang or time out when ROOT_URL is HTTPS but accessed over HTTP. Use the CLI path instead. - **Verify after deploy**: `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/` should return 200. The login page at `https://gitea.graj-media.com/user/login` should load without SSL warnings.