Files
2026-07-12 10:17:17 -04:00

8.4 KiB

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

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

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:
    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:
    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 "<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:

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:

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

# 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 '<title>[^<]*</title>'

# Login page renders
curl -sk https://gitea.graj-media.com/user/login | grep -c 'Sign In'

API Token Workflow

Generate a token for automation:

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:

curl -s -X POST http://127.0.0.1:3000/api/v1/user/repos \
  -H "Authorization: token <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:

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:

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):

#!/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:<TOKEN>@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:
    git init
    git add .
    git commit -m "initial commit"
    git remote add origin https://gitea.graj-media.com/ray/<repo>.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/