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:
- Stop the container (so web server isn't running)
- Set
INSTALL_LOCK = truein app.ini (or the env will be used on next start) - 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 - 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 - 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 runsschedule=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
- Login as
raywith the configured password - Click "New Repository" from the dashboard
- 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/