Compare commits

...

10 Commits

Author SHA1 Message Date
Deeman
7a1d031bc8 feat: disable Gitea self-registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:00:54 +01:00
Deeman
83843a6647 fix: re-enable Gitea SSH, expose on port 2222
SSH is needed for git operations (git@server:repo.git).
HTTP stays localhost-only (accessed via SSH tunnel for UI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:36:51 +01:00
Deeman
71a1386f13 fix: bind Gitea to 127.0.0.1 only — not exposed to internet
nginx proxy manager reaches it via localhost. No reason to bind to all interfaces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:31:18 +01:00
Deeman
4b1c2d1509 fix: change Gitea host port 3000 → 3100 (3000 already in use)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:29:25 +01:00
Deeman
08fd5e82c4 chore: recover compose files from running containers 2026-02-27 17:28:28 +01:00
Deeman
e8fb7eef38 feat: add reverse rsync to summary + use ssh hetzner_root in next steps
After bootstrap, prints rsync commands to pull recovered compose files
back to the workstation for committing. All server commands shown as
ssh hetzner_root '...' for copy-paste convenience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:27:26 +01:00
Deeman
0d65ed73f7 fix: cd /tmp before uv run to avoid /root/uv.toml permission error
sudo -u infra_service inherits the working dir (/root), causing uv to
fail reading /root/uv.toml. Running from /tmp avoids the permission issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:24:47 +01:00
Deeman
012aa13f3e fix: replace abandoned docker-autocompose with inline Python script
docker-autocompose 1.0.1 uses distutils (removed in Python 3.12) and
the old docker SDK (docker.Client). Replace with a small inline script
using docker>=7.0 + pyyaml, run via uv --with inline dependencies.

Uses com.docker.compose.service label for correct service names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:18:50 +01:00
Deeman
90258d8b98 fix: use correct uvx invocation for docker-autocompose
Package installs 'autocompose' executable, not 'docker-autocompose'.
Correct form: uvx --from docker-autocompose autocompose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:16:35 +01:00
Deeman
99e65eab50 refactor: two-phase setup — setup.sh (user/dirs/uv) + bootstrap.sh (recover/deploy)
Matches the beanflows pattern. No GitLab dependency — repo reaches the
server via rsync, Gitea becomes the remote once it's running.

setup.sh    — pipeable phase 1: infra_service user, /opt/server-infra,
              /data/server-infra, uv installation
bootstrap.sh — pipeable phase 2: validates prereqs, recovers umami +
              reverse-proxy compose files, creates data dirs, sets ownership

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 16:17:09 +01:00
5 changed files with 325 additions and 137 deletions

223
bootstrap.sh Normal file
View File

@@ -0,0 +1,223 @@
#!/bin/bash
# Phase 2: Recover compose files, deploy services, start Gitea.
# Pipeable — run after setup.sh and after rsync'ing the repo.
#
# Usage (from workstation):
# ssh root@<server-ip> 'bash -s' < bootstrap.sh
#
# Prerequisites:
# - setup.sh already run (infra_service user, dirs, uv)
# - Repo rsync'd to /opt/server-infra/
#
# What it does:
# 1. Validates prerequisites
# 2. Recovers compose files from running containers (umami, reverse-proxy)
# 3. Creates data directories
# 4. Sets correct ownership
# 5. Prints next steps (start Gitea, add proxy host, push repo to Gitea)
set -euo pipefail
SERVICE_USER="infra_service"
REPO_DIR="/opt/server-infra"
DATA_DIR="/data/server-infra"
UV="/home/${SERVICE_USER}/.local/bin/uv"
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
log() { echo "$(date '+%H:%M:%S') ==> $*"; }
warn() { echo "$(date '+%H:%M:%S') ==> WARNING: $*" >&2; }
# ── Preflight checks ───────────────────────────────────────────────────────────
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
echo "ERROR: ${SERVICE_USER} user not found. Run setup.sh first."
exit 1
fi
if [ ! -d "${REPO_DIR}/.git" ]; then
echo "ERROR: Repo not found at ${REPO_DIR}. Rsync the repo first:"
echo " rsync -av --chown=root:root ~/Projects/server-infra/ root@<server-ip>:${REPO_DIR}/"
exit 1
fi
command -v docker >/dev/null 2>&1 || { echo "ERROR: docker not found"; exit 1; }
[ -f "${UV}" ] || { echo "ERROR: uv not found at ${UV}. Run setup.sh first."; exit 1; }
# ── Inline autocompose script (replaces abandoned docker-autocompose package) ──
# Uses modern docker SDK. Written to /tmp, run via uv (fetches deps on demand).
cat > /tmp/server-infra-autocompose.py << 'PYEOF'
# /// script
# requires-python = ">=3.10"
# dependencies = ["docker>=7.0", "pyyaml>=6.0"]
# ///
"""Generate a docker-compose services block from running containers."""
import sys
import docker
import yaml
def container_to_service(container):
attrs = container.attrs
config = attrs["Config"]
host_config = attrs["HostConfig"]
service = {"image": config["Image"]}
restart = (host_config.get("RestartPolicy") or {}).get("Name", "no")
if restart and restart not in ("no", ""):
service["restart"] = restart
env = config.get("Env") or []
if env:
service["environment"] = env
port_bindings = host_config.get("PortBindings") or {}
if port_bindings:
ports = []
for container_port, bindings in port_bindings.items():
for binding in (bindings or []):
host_ip = binding.get("HostIp", "")
host_port = binding.get("HostPort", "")
if host_ip and host_ip not in ("0.0.0.0", "::"):
ports.append(f"{host_ip}:{host_port}:{container_port}")
elif host_port:
ports.append(f"{host_port}:{container_port}")
else:
ports.append(container_port)
if ports:
service["ports"] = ports
volumes = []
for mount in attrs.get("Mounts") or []:
dst = mount["Destination"]
if mount["Type"] == "bind":
src = mount["Source"]
mode = mount.get("Mode", "rw")
volumes.append(f"{src}:{dst}" if mode == "rw" else f"{src}:{dst}:{mode}")
elif mount["Type"] == "volume":
name = mount.get("Name", "")
if name:
volumes.append(f"{name}:{dst}")
if volumes:
service["volumes"] = volumes
return service
def main():
if len(sys.argv) < 2:
print("Usage: autocompose.py <container_name> [...]", file=sys.stderr)
sys.exit(1)
client = docker.from_env()
services = {}
for name in sys.argv[1:]:
try:
container = client.containers.get(name)
labels = (container.attrs["Config"].get("Labels") or {})
service_name = labels.get("com.docker.compose.service", name)
services[service_name] = container_to_service(container)
except docker.errors.NotFound:
print(f"WARNING: Container {name} not found", file=sys.stderr)
print(yaml.dump({"services": services}, default_flow_style=False, sort_keys=False))
if __name__ == "__main__":
main()
PYEOF
# ── Recover compose files from running containers ──────────────────────────────
recover_project() {
local outfile="$1"
shift
local containers=("$@")
local running=0
for c in "${containers[@]}"; do
if docker inspect "${c}" >/dev/null 2>&1; then
running=1
break
fi
done
if [ "${running}" = "0" ]; then
warn "None of [${containers[*]}] are running — skipping recovery."
return 0
fi
log "Recovering: ${containers[*]}"
(cd /tmp && sudo -u "${SERVICE_USER}" \
"${UV}" run /tmp/server-infra-autocompose.py "${containers[@]}") > "${outfile}"
log " Saved to ${outfile}"
}
log "Recovering compose files from running containers..."
recover_project \
"${REPO_DIR}/umami/docker-compose.yml" \
umami-umami-1 umami-db-1
recover_project \
"${REPO_DIR}/reverse-proxy/docker-compose.yml" \
reverse_proxy-app-1
# ── Data directories ───────────────────────────────────────────────────────────
log "Creating data directories..."
mkdir -p "${DATA_DIR}/gitea"
# ── Ownership ──────────────────────────────────────────────────────────────────
log "Setting ownership..."
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${REPO_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
# ── Summary ────────────────────────────────────────────────────────────────────
SERVER_IP=$(hostname -I | awk '{print $1}')
echo ""
echo "=================================================================="
echo ""
echo " Bootstrap complete."
echo ""
echo " Services ready in ${REPO_DIR}/:"
[ -f "${REPO_DIR}/umami/docker-compose.yml" ] && echo " umami (recovered)"
[ -f "${REPO_DIR}/reverse-proxy/docker-compose.yml" ] && echo " reverse-proxy (recovered)"
[ -f "${REPO_DIR}/gitea/docker-compose.yml" ] && echo " gitea (from repo)"
echo ""
echo " Service user: ${SERVICE_USER}"
echo " Data dir: ${DATA_DIR}/"
echo ""
echo "=================================================================="
echo ""
echo " Next steps (run from workstation):"
echo ""
echo " 1. Pull recovered compose files back to workstation:"
echo " rsync -av hetzner_root:${REPO_DIR}/umami/docker-compose.yml ~/Projects/server-infra/umami/"
echo " rsync -av hetzner_root:${REPO_DIR}/reverse-proxy/docker-compose.yml ~/Projects/server-infra/reverse-proxy/"
echo ""
echo " 2. Commit recovered files:"
echo " cd ~/Projects/server-infra"
echo " git add umami/docker-compose.yml reverse-proxy/docker-compose.yml"
echo " git commit -m 'chore: recover compose files from running containers'"
echo ""
echo " 3. Start Gitea:"
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} docker compose -f ${REPO_DIR}/gitea/docker-compose.yml up -d'"
echo " # Web installer at http://${SERVER_IP}:3000"
echo " # Set ROOT_URL to your public domain (e.g. https://git.yourdomain.com)"
echo ""
echo " 4. Add proxy host in nginx proxy manager → ${SERVER_IP}:3000"
echo ""
echo " 5. Push repo to Gitea:"
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} git -C ${REPO_DIR} remote add origin https://git.yourdomain.com/youruser/server-infra.git'"
echo " ssh hetzner_root 'sudo -u ${SERVICE_USER} git -C ${REPO_DIR} push -u origin master'"
echo ""
echo "=================================================================="
echo ""

View File

@@ -4,11 +4,12 @@ services:
container_name: gitea container_name: gitea
restart: always restart: always
ports: ports:
- "3000:3000" - "127.0.0.1:3100:3000"
- "2222:22"
volumes: volumes:
- /data/server-infra/gitea:/data - /data/server-infra/gitea:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
environment: environment:
- GITEA__database__DB_TYPE=sqlite3 - GITEA__database__DB_TYPE=sqlite3
- GITEA__server__SSH_DISABLED=true - GITEA__service__DISABLE_REGISTRATION=true

View File

@@ -0,0 +1,26 @@
services:
app:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
environment:
- TZ=Europe/Berlin
- PATH=/opt/certbot/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
- OPENRESTY_VERSION=1.27.1.2
- CROWDSEC_OPENRESTY_BOUNCER_VERSION=0.1.7
- CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
- SUPPRESS_NO_CONFIG_WARNING=1
- S6_BEHAVIOUR_IF_STAGE2_FAILS=1
- S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
- S6_FIX_ATTRS_HIDDEN=1
- S6_KILL_FINISH_MAXTIME=10000
- S6_VERBOSITY=1
- NODE_ENV=production
- NPM_BUILD_VERSION=2.13.6
- NPM_BUILD_COMMIT=f3efaae
- NPM_BUILD_DATE=2026-01-14 04:42:42 UTC
- NODE_OPTIONS=--openssl-legacy-provider
volumes:
- /home/beanflows_service_user/base_services/reverse_proxy/letsencrypt:/etc/letsencrypt
- /home/beanflows_service_user/base_services/reverse_proxy/data:/data

166
setup.sh
View File

@@ -1,104 +1,28 @@
#!/bin/bash #!/bin/bash
# Server shared-infra setup: recover compose files, create service user, deploy. # Phase 1: Create infra_service user, directories, and install uv.
# Run as root on the server. # Pipeable — no repo needed on the server.
# #
# Usage: # Usage (from workstation):
# sudo bash setup.sh # ssh root@<server-ip> 'bash -s' < setup.sh
# #
# What it does: # What it does:
# 1. Recovers docker-compose.yml files from already-running containers (one-time) # 1. Creates infra_service system user (nologin, docker group)
# 2. Creates infra_service system user (docker group, nologin) # 2. Creates /opt/server-infra/ and /data/server-infra/
# 3. Creates /data/server-infra/{service}/ data directories # 3. Installs uv as infra_service
# 4. Deploys all compose files from this repo to /opt/server-infra/ (idempotent)
# #
# Services managed by this repo: # After this script: rsync the repo, then run bootstrap.sh.
# umami — analytics (recovered from running containers)
# reverse-proxy — nginx proxy manager (recovered from running containers)
# gitea — self-hosted git (new, compose file already in repo)
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_USER="infra_service" SERVICE_USER="infra_service"
DEPLOY_DIR="/opt/server-infra" REPO_DIR="/opt/server-infra"
DATA_DIR="/data/server-infra"
[ "$(id -u)" = "0" ] || { echo "ERROR: Run as root: sudo bash setup.sh"; exit 1; } [ "$(id -u)" = "0" ] || { echo "ERROR: Run as root"; exit 1; }
log() { echo "$(date '+%H:%M:%S') ==> $*"; } log() { echo "$(date '+%H:%M:%S') ==> $*"; }
warn() { echo "$(date '+%H:%M:%S') ==> WARNING: $*" >&2; }
# ── Preflight checks ─────────────────────────────────────────────────────────── # ── Service user ───────────────────────────────────────────────────────────────
command -v docker >/dev/null 2>&1 || { echo "ERROR: docker not found"; exit 1; }
# Find uvx: prefer the installing user's local bin, fall back to PATH
UVX=""
for candidate in /root/.local/bin/uvx /home/Deeman/.local/bin/uvx /usr/local/bin/uvx "$(command -v uvx 2>/dev/null || true)"; do
if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then
UVX="${candidate}"
break
fi
done
[ -n "${UVX}" ] || { echo "ERROR: uvx not found — install uv first: curl -LsSf https://astral.sh/uv/install.sh | sh"; exit 1; }
log "Using uvx at ${UVX}"
# ── Helper: recover one compose project ───────────────────────────────────────
# Usage: recover_project <outfile> <container_name> [<container_name> ...]
recover_project() {
local outfile="$1"
shift
local containers=("$@")
# Check at least one container from the list is running
local running=0
for c in "${containers[@]}"; do
if docker inspect "${c}" >/dev/null 2>&1; then
running=1
break
fi
done
if [ "${running}" = "0" ]; then
warn "None of [${containers[*]}] are running — skipping recovery."
return 0
fi
log "Recovering compose file for: ${containers[*]}"
"${UVX}" docker-autocompose "${containers[@]}" > "${outfile}"
log "Saved to ${outfile}"
}
# ── Recover compose files ──────────────────────────────────────────────────────
log "Recovering compose files from running containers..."
mkdir -p /tmp/server-infra-recovery
recover_project \
/tmp/server-infra-recovery/umami.yml \
umami-umami-1 umami-db-1
recover_project \
/tmp/server-infra-recovery/reverse-proxy.yml \
reverse_proxy-app-1
# ── Copy recovered files into this repo ───────────────────────────────────────
log "Copying recovered files into repo (${SCRIPT_DIR})..."
for pair in \
"/tmp/server-infra-recovery/umami.yml:${SCRIPT_DIR}/umami/docker-compose.yml" \
"/tmp/server-infra-recovery/reverse-proxy.yml:${SCRIPT_DIR}/reverse-proxy/docker-compose.yml"; do
src="${pair%%:*}"
dst="${pair##*:}"
if [ -f "${src}" ]; then
mkdir -p "$(dirname "${dst}")"
cp "${src}" "${dst}"
log " ${dst}"
fi
done
# ── Service account ────────────────────────────────────────────────────────────
log "Creating service user ${SERVICE_USER}..." log "Creating service user ${SERVICE_USER}..."
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
@@ -109,69 +33,45 @@ else
fi fi
usermod -aG docker "${SERVICE_USER}" usermod -aG docker "${SERVICE_USER}"
# ── Data directories ─────────────────────────────────────────────────────────── # ── Directories ────────────────────────────────────────────────────────────────
log "Creating data directories..." log "Creating directories..."
mkdir -p /data/server-infra/gitea mkdir -p "${REPO_DIR}" "${DATA_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" /data/server-infra chown "${SERVICE_USER}:${SERVICE_USER}" "${REPO_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}"
# ── Deploy: copy all compose files from repo to deploy dir (idempotent) ──────── # ── uv ────────────────────────────────────────────────────────────────────────
#
# Recovered files (umami, reverse-proxy) are already in ${SCRIPT_DIR} after
# the recovery section above. Gitea compose is committed directly in the repo.
# This section deploys all of them to DEPLOY_DIR.
log "Deploying compose files to ${DEPLOY_DIR}..." if [ ! -f "/home/${SERVICE_USER}/.local/bin/uv" ]; then
log "Installing uv..."
for service in umami reverse-proxy gitea; do sudo -u "${SERVICE_USER}" bash -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'
src="${SCRIPT_DIR}/${service}/docker-compose.yml" log " uv installed."
dst="${DEPLOY_DIR}/${service}/docker-compose.yml"
if [ -f "${src}" ]; then
mkdir -p "${DEPLOY_DIR}/${service}"
cp "${src}" "${dst}"
log " ${service}${dst}"
else else
warn " ${service}/docker-compose.yml not in repo — skipping deploy" log " uv already installed — skipping."
fi fi
done
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEPLOY_DIR}"
# ── Summary ──────────────────────────────────────────────────────────────────── # ── Summary ────────────────────────────────────────────────────────────────────
SERVER_IP=$(hostname -I | awk '{print $1}')
echo "" echo ""
echo "==================================================================" echo "=================================================================="
echo "" echo ""
echo " Setup complete." echo " Phase 1 complete."
echo ""
echo " Deployed services:"
[ -f "${DEPLOY_DIR}/umami/docker-compose.yml" ] && echo " umami → ${DEPLOY_DIR}/umami/docker-compose.yml"
[ -f "${DEPLOY_DIR}/reverse-proxy/docker-compose.yml" ] && echo " reverse-proxy → ${DEPLOY_DIR}/reverse-proxy/docker-compose.yml"
[ -f "${DEPLOY_DIR}/gitea/docker-compose.yml" ] && echo " gitea → ${DEPLOY_DIR}/gitea/docker-compose.yml"
echo "" echo ""
echo " Service user: ${SERVICE_USER} (docker group)" echo " Service user: ${SERVICE_USER} (docker group)"
echo " Deploy dir: ${DEPLOY_DIR}/" echo " Repo dir: ${REPO_DIR}/"
echo " Data dir: /data/server-infra/" echo " Data dir: ${DATA_DIR}/"
echo "" echo ""
echo "==================================================================" echo "=================================================================="
echo "" echo ""
echo " Next steps:" echo " Next step — rsync repo from workstation, then run bootstrap:"
echo "" echo ""
echo " 1. Commit recovered compose files to git:" echo " 1. On your workstation:"
echo " cd ${SCRIPT_DIR}" echo " rsync -av --chown=root:root ~/Projects/server-infra/ root@${SERVER_IP}:${REPO_DIR}/"
echo " git add umami/docker-compose.yml reverse-proxy/docker-compose.yml"
echo " git commit -m 'chore: recover compose files from running containers'"
echo "" echo ""
echo " 2. Start Gitea:" echo " 2. Then run bootstrap:"
echo " sudo -u ${SERVICE_USER} docker compose -f ${DEPLOY_DIR}/gitea/docker-compose.yml up -d" echo " ssh root@${SERVER_IP} 'bash -s' < bootstrap.sh"
echo " # Then open http://<server-ip>:3000 to complete the web installer"
echo " # Set ROOT_URL to your public domain (e.g. https://git.yourdomain.com)"
echo ""
echo " 3. Add Gitea proxy host in nginx proxy manager:"
echo " Forward to <server-ip>:3000"
echo ""
echo " 4. To restart any service if containers go down:"
echo " sudo -u ${SERVICE_USER} docker compose -f ${DEPLOY_DIR}/<service>/docker-compose.yml up -d"
echo "" echo ""
echo "==================================================================" echo "=================================================================="
echo "" echo ""

38
umami/docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
umami:
image: ghcr.io/umami-software/umami:latest
restart: always
environment:
- DATABASE_URL=postgresql://umami:umami@db:5432/umami
- APP_SECRET=V896IlIKJUiwZrUMq6AbKETbBVIZqMFT8dmWBQdC0e
- TRACKER_SCRIPT_NAME=Z.js
- COLLECT_API_ENDPOINT=/api/Z
- TOKEN_EXPIRATION_TIME=365d
- PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- NODE_VERSION=22.21.1
- YARN_VERSION=1.22.22
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- NODE_OPTIONS=
- HOSTNAME=0.0.0.0
- PORT=3000
ports:
- 8300:3000/tcp
db:
image: postgres:15-alpine
restart: always
environment:
- POSTGRES_PASSWORD=umami
- POSTGRES_DB=umami
- POSTGRES_USER=umami
- PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- GOSU_VERSION=1.19
- LANG=en_US.utf8
- PG_MAJOR=15
- PG_VERSION=15.16
- PG_SHA256=695ee29a77be1f5010e10f3667696f29871587f7aa311eadc1f809bea287cf48
- "DOCKER_PG_LLVM_DEPS=llvm19-dev \t\tclang19"
- PGDATA=/var/lib/postgresql/data
volumes:
- umami_umami-db-data:/var/lib/postgresql/data