# Gitea Self-Hosted Git Service ## Deployment | Detail | Value | |--------|-------| | Container | `gitea/gitea:latest` | | Data path | `/mnt/seagate8tb/docker/gitea/` | | Internal port | 3000 (HTTP) | | SSL port | 443 | | Auth | Local (ray / password) | | URL | `https://gitea.graj-media.com` | ## Docker Compose ```yaml services: gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped volumes: - ./data:/data - ./config:/etc/gitea - /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 ``` ## Nginx Config ```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; } } ``` ## Critical Pitfalls ### 1. Docker Entrypoint Overrides app.ini The Gitea Docker entrypoint rewrites `/data/gitea/conf/app.ini` from environment variables **every time the container starts**. Manual edits to app.ini are silently overwritten. **Fix:** Use `GITEA__section__key=value` environment variables in docker-compose.yml, not direct file edits. The double-underscore delimiter maps to INI sections: `GITEA__server__DISABLE_SSH=true` sets `[server] DISABLE_SSH = true`. After the container starts, the entrypoint generates the config from env vars first, then Gitea's web runner applies `INSTALL_LOCK = true` from the env if set. ### 2. SSH Port Conflict The Gitea Docker image includes an OpenSSH daemon that automatically binds to port 22 inside the container. When `START_SSH_SERVER=true` (default), Gitea also tries to bind its own SSH server on port 22 → fatal error → container restarts in a crash loop. **Fix:** Set `GITEA__server__DISABLE_SSH=true` in environment. This prevents Gitea from starting its SSH server. HTTPS cloning still works through the web interface. If SSH-based git access is needed later, configure a non-conflicting internal port with `SSH_LISTEN_PORT`. **Detection in logs:** ``` [F] Failed to start SSH server: listen tcp :22: bind: address already in use Received signal 15; terminating. ``` ### 3. Admin Account Setup (Headless / API-First) Gitea's install page requires browser interaction. To set up without the web UI: 1. **Stop the container** (so web server isn't running) 2. **Set `INSTALL_LOCK = true`** in app.ini (or the env will be used on next start) 3. **Run migration** to create the database schema: ```bash docker run --rm \ -v /mnt/seagate8tb/docker/gitea/data:/data \ -v /mnt/seagate8tb/docker/gitea/config:/etc/gitea \ --user 1000:1000 \ gitea/gitea:latest \ gitea migrate ``` 4. **Create admin user:** ```bash docker run --rm \ -v /mnt/seagate8tb/docker/gitea/data:/data \ -v /mnt/seagate8tb/docker/gitea/config:/etc/gitea \ --user 1000:1000 \ gitea/gitea:latest \ gitea admin user create --username ray --password "" --email ray@grajmedia.duckdns.org --admin ``` 5. **Start container** — it now runs as an installed instance with the admin user ready **Alternative** (if container must stay running): Navigate to the install page in a browser, fill admin fields, and submit. The browser form works when the install page is served. ### 4. Secret Key Generation Generate all secrets outside the container using the Gitea binary: ```bash gitea generate secret SECRET_KEY gitea generate secret JWT_SECRET gitea generate secret LFS_JWT_SECRET gitea generate secret INTERNAL_TOKEN ``` These can be passed as env vars or written to app.ini. Write them to the config file **before** the first migration to avoid key rotation issues. ### 5. must_change_password Blocks API If API calls return `"You must change your password"`, clear the flag: ```bash docker exec gitea sqlite3 /data/gitea/gitea.db \ "UPDATE user SET must_change_password=0 WHERE name='ray';" ``` This happens when the password was changed via the admin CLI (`gitea admin user change-password`) but the `must_change_password` flag wasn't cleared. ## Verification ```bash # Container health docker ps --filter name=gitea --format '{{.Status}}' # HTTP response curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ # Should return 200 # SSL via nginx curl -sk https://gitea.graj-media.com/ | grep -o '[^<]*' # Login page renders curl -sk https://gitea.graj-media.com/user/login | grep -c 'Sign In' ``` ## API Token Workflow Generate a token for automation: ```bash docker exec -u git gitea gitea admin user generate-access-token \ --username ray --token-name "automation" --scopes "all" 2>&1 | grep -v "^$" ``` Create repos via API: ```bash curl -s -X POST http://127.0.0.1:3000/api/v1/user/repos \ -H "Authorization: token " \ -H "Content-Type: application/json" \ -d '{"name":"my-repo","private":false}' ``` ### Push code with URL-encoded password Encode `#` as `%23`, `^` as `%5E`, `$` as `%24`: ```bash git remote add origin http://ray:4W%23UxJ%5EacTrdPT@127.0.0.1:3000/ray/repo.git git branch -m master main git push -u origin main ``` ## Workflow Automation ### Git Save Alias One-command add+commit+push. Works in any repo: ```bash git config --global alias.save '!f() { git add -A && git commit -m "$*" && git push; }; f' ``` Usage: `git save "message describing changes"` ### Daily Autosave Cron (no_agent) Script that auto-commits any repo with pending changes at 2 AM daily. Uses Hermes `no_agent=true` cron — only produces output when changes were actually saved (silent when clean). **Script** (`~/.hermes/scripts/git-autosave.sh`): ```bash #!/bin/bash set -euo pipefail DATE=$(date '+%Y-%m-%d') LOGFILE="$HOME/.hermes/logs/git-autosave.log" mkdir -p "$HOME/.hermes/logs" REPOS=( "/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2:main" "/mnt/seagate8tb/docker:main" ) for entry in "${REPOS[@]}"; do DIR="${entry%%:*}" BRANCH="${entry##*:}" if [ ! -d "$DIR/.git" ]; then echo "[$DATE] SKIP $DIR — no .git" >> "$LOGFILE" continue fi cd "$DIR" if [ -z "$(git status --porcelain 2>/dev/null)" ]; then echo "[$DATE] CLEAN $DIR" >> "$LOGFILE" continue fi git pull --rebase origin "$BRANCH" 2>/dev/null || true git add -A git commit -m "auto-save $DATE" git push origin "$BRANCH" echo "[$DATE] SAVED $DIR — auto-save $DATE" >> "$LOGFILE" done ``` **Cron job setup** (via Hermes cronjob tool): - `no_agent=true` — the script IS the job, its stdout is delivered verbatim (or silently when empty) - `deliver=local` — logs saved, no notification on clean runs - `schedule=0 2 * * *` — daily at 2 AM **Pitfall:** Remotes must use token-based auth (not password) for unattended pushes. Set up with `git remote set-url origin http://ray:@127.0.0.1:3000/ray/repo.git` to avoid credential prompts. ## First Use 1. Login as `ray` with the configured password 2. Click "New Repository" from the dashboard 3. Create your first repo and push: ```bash git init git add . git commit -m "initial commit" git remote add origin https://gitea.graj-media.com/ray/.git git push -u origin main ``` ## Data Layout ``` /mnt/seagate8tb/docker/gitea/ ├── docker-compose.yml ├── data/ │ ├── gitea/ │ │ ├── conf/app.ini # Config (overwritten by entrypoint) │ │ ├── gitea.db # SQLite database │ │ └── log/ │ └── git/ │ └── repositories/ # Actual git repos └── config/ ```