276 lines
8.4 KiB
Markdown
276 lines
8.4 KiB
Markdown
# 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 "<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 '<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:
|
|
```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 <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:<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:
|
|
```bash
|
|
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/
|
|
```
|