diff --git a/.env b/.env index 8f0a2c6..a31cf89 100644 --- a/.env +++ b/.env @@ -1,30 +1,47 @@ # Gitea container settings -GITEA_IMAGE=gitea/gitea:latest +GITEA_IMAGE=gitea/gitea:1.26 GITEA_CONTAINER_NAME=gitea -GITEA_RESTART_POLICY=always +GITEA_RESTART_POLICY=unless-stopped # User / time zone GITEA_UID=1000 GITEA_GID=1000 GITEA_TZ=Europe/Rome -# Database (SQLite) -GITEA_DB_PATH=/data/gitea/gitea.db +# Database (MySQL) +# Generate the password with: openssl rand -base64 32 +MYSQL_IMAGE=mysql:8.4 +MYSQL_CONTAINER_NAME=gitea-db +MYSQL_DATABASE=gitea +MYSQL_USER=gitea +MYSQL_PASSWORD=CHANGE_ME +# Tune to the host: ~50-70% of available RAM on a dedicated box +MYSQL_INNODB_BUFFER_POOL_SIZE=512M +MYSQL_MAX_CONNECTIONS=200 # Server URLs and ports GITEA_DOMAIN=git.pyconetwork.it -GITEA_SSH_DOMAIN=$GITEA_DOMAIN -GITEA_ROOT_URL=https://$GITEA_DOMAIN/ +GITEA_SSH_DOMAIN=${GITEA_DOMAIN} +GITEA_ROOT_URL=https://${GITEA_DOMAIN}/ +# http is correct here: TLS is terminated by the proxy in front of nginx GITEA_PROTOCOL=http -GITEA_HTTP_PORT=3000 GITEA_SSH_PORT=2222 +# Leave false: the image's own sshd serves git over SSH on container port 22 GITEA_START_SSH_SERVER=false +# Security +# INSTALL_LOCK=true skips the web installer (everything it asks is already set +# above). Create the first admin from the CLI instead - see README.md. +GITEA_INSTALL_LOCK=true +GITEA_DISABLE_REGISTRATION=true +GITEA_REQUIRE_SIGNIN_VIEW=false + # Bind mount locations GITEA_DATA_DIR=./data GITEA_CONFIG_DIR=./config # Nginx reverse proxy +NGINX_IMAGE=nginx:1.27-alpine NGINX_CONTAINER_NAME=gitea-nginx NGINX_HTTP_PORT=80 NGINX_CONFIG_DIR=./nginx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b1eeee --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Copy to .env and fill in the secrets. Do not commit .env. + +# Gitea container settings +GITEA_IMAGE=gitea/gitea:1.26 +GITEA_CONTAINER_NAME=gitea +GITEA_RESTART_POLICY=unless-stopped + +# User / time zone +GITEA_UID=1000 +GITEA_GID=1000 +GITEA_TZ=Europe/Rome + +# Database (MySQL) +# Generate the password with: openssl rand -base64 32 +MYSQL_IMAGE=mysql:8.4 +MYSQL_CONTAINER_NAME=gitea-db +MYSQL_DATABASE=gitea +MYSQL_USER=gitea +MYSQL_PASSWORD=CHANGE_ME +# Tune to the host: ~50-70% of available RAM on a dedicated box +MYSQL_INNODB_BUFFER_POOL_SIZE=512M +MYSQL_MAX_CONNECTIONS=200 + +# Server URLs and ports +GITEA_DOMAIN=git.pyconetwork.it +GITEA_SSH_DOMAIN=${GITEA_DOMAIN} +GITEA_ROOT_URL=https://${GITEA_DOMAIN}/ +# http is correct here: TLS is terminated by the proxy in front of nginx +GITEA_PROTOCOL=http +GITEA_SSH_PORT=2222 +# Leave false: the image's own sshd serves git over SSH on container port 22 +GITEA_START_SSH_SERVER=false + +# Security +# INSTALL_LOCK=true skips the web installer (everything it asks is already set +# above). Create the first admin from the CLI instead - see README.md. +GITEA_INSTALL_LOCK=true +GITEA_DISABLE_REGISTRATION=true +GITEA_REQUIRE_SIGNIN_VIEW=false + +# Bind mount locations +GITEA_DATA_DIR=./data +GITEA_CONFIG_DIR=./config + +# Nginx reverse proxy +NGINX_IMAGE=nginx:1.27-alpine +NGINX_CONTAINER_NAME=gitea-nginx +NGINX_HTTP_PORT=80 +NGINX_CONFIG_DIR=./nginx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db04d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Secrets +.env + +# Runtime state (bind mounts) +/data/ +/config/ + +# Backups +*.sql +*.sql.gz +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ebbe0c --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Gitea + +Self-hosted Gitea behind nginx, backed by MySQL. + +```text +internet -> (TLS terminated upstream) -> nginx :80 -> gitea :3000 -> mysql :3306 +``` + +Three services on two networks: `frontend` (nginx + gitea) and `backend` +(gitea + db, `internal`, so the database has no route out). + +## Setup + +```bash +cp .env.example .env +openssl rand -base64 32 # put the result in MYSQL_PASSWORD +./setup.sh +``` + +`setup.sh` creates the bind mounts, fixes their ownership, and brings the stack +up. It refuses to start if `MYSQL_PASSWORD` is still the placeholder. + +The web installer is disabled (`GITEA_INSTALL_LOCK=true`) because everything it +would ask is already set in `.env`. Create the first admin from the CLI: + +```bash +docker compose exec -u git gitea gitea admin user create \ + --username admin --email you@example.com --random-password --admin +``` + +Registration is closed by default (`GITEA_DISABLE_REGISTRATION=true`); flip it +in `.env` if you want open signups. + +## TLS + +The stack terminates nothing: nginx listens on plain `:80` and expects TLS to be +handled by whatever sits in front (Cloudflare, a host-level proxy, a load +balancer). That upstream must send `X-Forwarded-Proto: https`, otherwise Gitea +builds redirects with the wrong scheme. `GITEA_PROTOCOL=http` with an `https://` +`GITEA_ROOT_URL` is correct for this layout. + +If you also want the real client IP in Gitea's logs, uncomment the +`set_real_ip_from` block in [nginx/gitea.conf](nginx/gitea.conf) and point it at +the upstream proxy's network. + +## SSH + +Git over SSH runs on the host port in `GITEA_SSH_PORT` (2222), mapped to port 22 +in the container, where the image's own OpenSSH daemon serves it. +`GITEA_SSH_PORT` only tells Gitea which port to advertise in clone URLs. + +Leave `GITEA_START_SSH_SERVER=false`. Setting it to `true` makes Gitea's +built-in SSH server try to bind port 22, which sshd already holds, and the +container exits with `bind: address already in use`. + +## Backup + +```bash +# Database +docker compose exec db mysqldump \ + -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" --single-transaction "$MYSQL_DATABASE" \ + | gzip > gitea-db-$(date +%F).sql.gz + +# Repositories, avatars, LFS +tar czf gitea-data-$(date +%F).tar.gz data/ +``` + +Restore the database with `docker compose exec -T db mysql -u... -p... gitea < +dump.sql`, then restore `data/` and start the stack. + +## Upgrading + +`GITEA_IMAGE` is pinned to a minor series (`gitea/gitea:1.26`) so `docker +compose pull` picks up patch releases but never jumps a major version, which +would migrate the database irreversibly. Back up before changing the series, +and read the release notes for that jump. diff --git a/docker-compose.yml b/docker-compose.yml index a926134..f1d5298 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,43 +1,118 @@ +name: gitea + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + services: nginx: - image: nginx:alpine + image: ${NGINX_IMAGE:-nginx:1.27-alpine} container_name: ${NGINX_CONTAINER_NAME:-gitea-nginx} - restart: ${GITEA_RESTART_POLICY} + restart: ${GITEA_RESTART_POLICY:-unless-stopped} ports: - "${NGINX_HTTP_PORT:-80}:80" volumes: - ${NGINX_CONFIG_DIR:-./nginx}:/etc/nginx/conf.d:ro depends_on: - - gitea + gitea: + condition: service_healthy networks: - - gitea-network + - frontend + logging: *default-logging gitea: - image: ${GITEA_IMAGE:-gitea/gitea} - container_name: ${GITEA_CONTAINER_NAME} - restart: ${GITEA_RESTART_POLICY} - env_file: - - .env + image: ${GITEA_IMAGE:-gitea/gitea:1.26} + container_name: ${GITEA_CONTAINER_NAME:-gitea} + restart: ${GITEA_RESTART_POLICY:-unless-stopped} environment: - - USER_UID=${GITEA_UID} - - USER_GID=${GITEA_GID} - - TZ=${GITEA_TZ} - - GITEA__database__DB_TYPE=sqlite3 - - GITEA__database__PATH=${GITEA_DB_PATH:-/data/gitea/gitea.db} - - GITEA__server__DOMAIN=${GITEA_DOMAIN} - - GITEA__server__ROOT_URL=${GITEA_ROOT_URL} - - GITEA__server__SSH_DOMAIN=${GITEA_SSH_DOMAIN} - - GITEA__server__PROTOCOL=${GITEA_PROTOCOL} - - GITEA__server__SSH_PORT=${GITEA_SSH_PORT} - - GITEA__server__START_SSH_SERVER=${GITEA_START_SSH_SERVER} + USER_UID: "${GITEA_UID:-1000}" + USER_GID: "${GITEA_GID:-1000}" + TZ: "${GITEA_TZ:-Europe/Rome}" + + # --- Database (MySQL) --- + GITEA__database__DB_TYPE: mysql + GITEA__database__HOST: "db:3306" + GITEA__database__NAME: "${MYSQL_DATABASE:-gitea}" + GITEA__database__USER: "${MYSQL_USER:-gitea}" + GITEA__database__PASSWD: "${MYSQL_PASSWORD:?MYSQL_PASSWORD is not set - copy .env.example to .env and set it}" + GITEA__database__SSL_MODE: "false" + GITEA__database__CHARSET: "utf8mb4" + + # --- Server --- + GITEA__server__DOMAIN: "${GITEA_DOMAIN}" + GITEA__server__ROOT_URL: "${GITEA_ROOT_URL}" + GITEA__server__PROTOCOL: "${GITEA_PROTOCOL:-http}" + GITEA__server__HTTP_PORT: "3000" + # git over SSH is served by the image's own OpenSSH daemon on container + # port 22, so START_SSH_SERVER stays false - turning it on makes Gitea's + # built-in server race sshd for port 22 and the container dies. + # SSH_PORT is only what Gitea advertises in clone URLs (the host port). + GITEA__server__SSH_DOMAIN: "${GITEA_SSH_DOMAIN}" + GITEA__server__START_SSH_SERVER: "${GITEA_START_SSH_SERVER:-false}" + GITEA__server__SSH_PORT: "${GITEA_SSH_PORT:-2222}" + + # --- Security / service --- + GITEA__security__INSTALL_LOCK: "${GITEA_INSTALL_LOCK:-true}" + GITEA__service__DISABLE_REGISTRATION: "${GITEA_DISABLE_REGISTRATION:-true}" + GITEA__service__REQUIRE_SIGNIN_VIEW: "${GITEA_REQUIRE_SIGNIN_VIEW:-false}" volumes: - - ${GITEA_DATA_DIR}:/data - - ${GITEA_CONFIG_DIR}:/etc/gitea + - ${GITEA_DATA_DIR:-./data}:/data + - ${GITEA_CONFIG_DIR:-./config}:/etc/gitea ports: - - "${GITEA_SSH_PORT}:22" # SSH for git over SSH + - "${GITEA_SSH_PORT:-2222}:22" # git over SSH + depends_on: + db: + condition: service_healthy networks: - - gitea-network + - frontend + - backend + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/api/healthz"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s + logging: *default-logging + + db: + image: ${MYSQL_IMAGE:-mysql:8.4} + container_name: ${MYSQL_CONTAINER_NAME:-gitea-db} + restart: ${GITEA_RESTART_POLICY:-unless-stopped} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_as_cs + - --innodb-buffer-pool-size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} + - --max-connections=${MYSQL_MAX_CONNECTIONS:-200} + environment: + # No fixed root password is stored anywhere: the app user below is all + # Gitea needs. The generated root password is printed once to the log. + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_DATABASE: "${MYSQL_DATABASE:-gitea}" + MYSQL_USER: "${MYSQL_USER:-gitea}" + MYSQL_PASSWORD: "${MYSQL_PASSWORD:?MYSQL_PASSWORD is not set - copy .env.example to .env and set it}" + TZ: "${GITEA_TZ:-Europe/Rome}" + volumes: + - gitea-db:/var/lib/mysql + networks: + - backend + healthcheck: + # Ping over TCP, not the socket: during init MySQL runs a temporary + # socket-only server, so this cannot report ready too early. + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$$MYSQL_USER\" -p\"$$MYSQL_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + logging: *default-logging networks: - gitea-network: - driver: bridge \ No newline at end of file + frontend: + driver: bridge + backend: + driver: bridge + internal: true # the database has no route to the internet + +volumes: + gitea-db: diff --git a/nginx/gitea.conf b/nginx/gitea.conf index 9700f47..59fdb94 100644 --- a/nginx/gitea.conf +++ b/nginx/gitea.conf @@ -13,16 +13,29 @@ server { client_max_body_size 512M; + # If TLS is terminated by another proxy in front of this one, uncomment and + # set its address so Gitea logs and rate limits see the real client IP + # instead of the proxy's. + # set_real_ip_from 10.0.0.0/8; + # real_ip_header X-Forwarded-For; + # real_ip_recursive on; + location / { proxy_pass http://gitea; + 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 $forwarded_proto; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; - + proxy_buffering off; proxy_request_buffering off; + + # Large clones and pushes take longer than the 60s default. + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; } } diff --git a/setup.sh b/setup.sh index d39c608..629d86d 100644 --- a/setup.sh +++ b/setup.sh @@ -4,11 +4,20 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env" -if [ -f "$ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a +if [ ! -f "$ENV_FILE" ]; then + echo "No .env found. Copy .env.example to .env and set MYSQL_PASSWORD." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +. "$ENV_FILE" +set +a + +if [ -z "${MYSQL_PASSWORD:-}" ] || [ "${MYSQL_PASSWORD}" = "CHANGE_ME" ]; then + echo "MYSQL_PASSWORD is unset or still the placeholder in .env." >&2 + echo "Generate one with: openssl rand -base64 32" >&2 + exit 1 fi : "${GITEA_UID:=1000}" @@ -30,6 +39,8 @@ NGINX_DIR="$(resolve_path "$NGINX_CONFIG_DIR")" mkdir -p "$DATA_DIR" "$CONFIG_DIR" "$NGINX_DIR" +# Only the Gitea bind mounts need this; MySQL uses a named volume and manages +# its own ownership. chown_cmd=(chown -R "${GITEA_UID}:${GITEA_GID}" "$DATA_DIR" "$CONFIG_DIR") if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then @@ -52,3 +63,12 @@ else fi (cd "$SCRIPT_DIR" && "${COMPOSE_CMD[@]}" up -d) + +cat <