initial commit
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
---
|
||||
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 = <run: gitea generate secret SECRET_KEY>
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = <run: gitea generate secret JWT_SECRET>
|
||||
|
||||
[git]
|
||||
LFS_JWT_SECRET = <run: gitea generate secret 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 "<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="<token-from-above>"
|
||||
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 <command>` 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=<different>`.
|
||||
- **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.
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# Auto-save any repos with uncommitted changes to Gitea
|
||||
# Runs daily via cron — stdout is delivered to Telegram
|
||||
# Add new repos in the REPOS array below.
|
||||
# For root-owned dirs, add ":sudo" as the third colon-delimited field.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATE=$(date '+%Y-%m-%d')
|
||||
LOGFILE="$HOME/.hermes/logs/git-autosave.log"
|
||||
mkdir -p "$HOME/.hermes/logs"
|
||||
|
||||
# Format: path:branch:prefix
|
||||
# prefix: 'sudo' for root-owned dirs, empty for normal
|
||||
REPOS=(
|
||||
"/path/to/project-1:main"
|
||||
"/path/to/project-2:main"
|
||||
"/etc/nginx:main:sudo"
|
||||
)
|
||||
|
||||
SAVED=()
|
||||
CLEAN=()
|
||||
ERRORS=()
|
||||
|
||||
for entry in "${REPOS[@]}"; do
|
||||
IFS=':' read -r DIR BRANCH PREFIX <<< "$entry"
|
||||
NAME=$(basename "$DIR")
|
||||
GIT="${PREFIX:+sudo }git"
|
||||
|
||||
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 $NAME" >> "$LOGFILE"
|
||||
CLEAN+=("$NAME")
|
||||
continue
|
||||
fi
|
||||
|
||||
$GIT pull --rebase origin "$BRANCH" 2>/dev/null || true
|
||||
$GIT add -A
|
||||
|
||||
if $GIT commit -m "auto-save $DATE"; then
|
||||
if $GIT push origin "$BRANCH" 2>/dev/null; then
|
||||
echo "[$DATE] SAVED $NAME" >> "$LOGFILE"
|
||||
SAVED+=("$NAME")
|
||||
else
|
||||
echo "[$DATE] PUSH FAILED $NAME" >> "$LOGFILE"
|
||||
ERRORS+=("$NAME (push failed)")
|
||||
fi
|
||||
else
|
||||
echo "[$DATE] COMMIT FAILED $NAME" >> "$LOGFILE"
|
||||
ERRORS+=("$NAME (commit failed)")
|
||||
fi
|
||||
done
|
||||
|
||||
MSG=""
|
||||
if [ ${#SAVED[@]} -gt 0 ]; then
|
||||
MSG="${MSG}✓ Saved: ${SAVED[*]}\n"
|
||||
fi
|
||||
if [ ${#CLEAN[@]} -gt 0 ]; then
|
||||
MSG="${MSG}— Clean: ${CLEAN[*]}\n"
|
||||
fi
|
||||
if [ ${#ERRORS[@]} -gt 0 ]; then
|
||||
MSG="${MSG}✗ Errors: ${ERRORS[*]}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$MSG" ]; then
|
||||
echo -e "Gitea auto-save $DATE\n${MSG}"
|
||||
fi
|
||||
Reference in New Issue
Block a user