diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh old mode 100644 new mode 100755 index e7cac707..15be8b07 --- a/scripts/backup-databases.sh +++ b/scripts/backup-databases.sh @@ -2,6 +2,7 @@ # ============================================================================= # HomeLab Database Backup Script # Backs up PostgreSQL, Redis, and MariaDB to timestamped archives. +# Includes automatic retention (default 7 days). # Usage: ./backup-databases.sh [--postgres|--redis|--mariadb|--all] # ============================================================================= set -euo pipefail @@ -10,14 +11,27 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) ROOT_DIR=$(dirname "$SCRIPT_DIR") BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/backups/databases}" TIMESTAMP=$(date +%Y%m%d_%H%M%S) +RETENTION_DAYS="${RETENTION_DAYS:-7}" -RED=''; GREEN=''; YELLOW=''; RESET='' +RED=''; GREEN=''; YELLOW=''; RESET='' log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } mkdir -p "$BACKUP_DIR" +cleanup_old_backups() { + log_info "Cleaning up backups older than ${RETENTION_DAYS} days..." + local count + count=$(find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" | wc -l | tr -d ' ') + if [ "$count" -gt 0 ]; then + find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete + log_info "Removed $count old backup(s)" + else + log_info "No old backups to clean up" + fi +} + backup_postgres() { log_info "Backing up PostgreSQL..." local file="$BACKUP_DIR/postgres_${TIMESTAMP}.sql.gz" @@ -49,6 +63,7 @@ case "${1:---all}" in backup_postgres backup_redis backup_mariadb + cleanup_old_backups log_info "All backups completed in $BACKUP_DIR" ;; *) echo "Usage: $0 [--postgres|--redis|--mariadb|--all]"; exit 1 ;; diff --git a/stacks/databases/.env.example b/stacks/databases/.env.example index ca52ed66..dd8f51fb 100644 --- a/stacks/databases/.env.example +++ b/stacks/databases/.env.example @@ -1,12 +1,23 @@ # Database Stack +DOMAIN=example.com POSTGRES_ROOT_USER=postgres -POSTGRES_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD -REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD -MARIADB_ROOT_PASSWORD=CHANGE_ME_MARIADB_PASSWORD +POSTGRES_ROOT_PASSWORD=CHANGEME_STRONG_PASSWORD +REDIS_PASSWORD=CHANGEME_STRONG_PASSWORD +MARIADB_ROOT_PASSWORD=CHANGEME_STRONG_PASSWORD -# Per-service passwords -NEXTCLOUD_DB_PASSWORD=CHANGE_ME -GITEA_DB_PASSWORD=CHANGE_ME -OUTLINE_DB_PASSWORD=CHANGE_ME -VAULTWARDEN_DB_PASSWORD=CHANGE_ME -BOOKSTACK_DB_PASSWORD=CHANGE_ME +# pgAdmin +PGADMIN_EMAIL=admin@homelab.local +PGADMIN_PASSWORD=CHANGEME_STRONG_PASSWORD + +# Redis Commander +REDIS_COMMANDER_USER=admin +REDIS_COMMANDER_PASSWORD=CHANGEME_STRONG_PASSWORD + +# Per-service PostgreSQL passwords +NEXTCLOUD_DB_PASSWORD=CHANGEME_NEXTCLOUD +GITEA_DB_PASSWORD=CHANGEME_GITEA +OUTLINE_DB_PASSWORD=CHANGEME_OUTLINE +AUTHENTIK_DB_PASSWORD=CHANGEME_AUTHENTIK +GRAFANA_DB_PASSWORD=CHANGEME_GRAFANA +VAULTWARDEN_DB_PASSWORD=CHANGEME_VAULTWARDEN +BOOKSTACK_DB_PASSWORD=CHANGEME_BOOKSTACK diff --git a/stacks/databases/.gitkeep b/stacks/databases/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/stacks/databases/README.md b/stacks/databases/README.md new file mode 100644 index 00000000..8f5c0875 --- /dev/null +++ b/stacks/databases/README.md @@ -0,0 +1,40 @@ +# Database Stack + +Shared database layer for HomeLab services. + +## Services + +- **PostgreSQL** (postgres:16-alpine) - Primary multi-tenant database (internal) +- **Redis** redis:7-alpine) - Cache/message queue (internal) +- **MariABDJ** (mariadb:11.4) - MySQL-compatible DB (internal) +- **pgAdmin** (dpage/pgadmin4:latest) - PostgreSQL admin ui (via Traefik) +- **Redis Commander** (rediscommander/redis-commander:latest) - Redis admin ui (via Traefik) + +## PostgreSQL Connection Strings + +- postgresql://nextcloud:PASS@homelab-postgres:5432/nextcloud +- postgresql://gitea:PAS@@homelab-postgres:5432/gitea +- postgresql://outline:PASS@homelab-postgres:5432/outline +- postgresql://authentik:PASS@homelab-postgres:5432/authentik - postgresql://grafana:PASS@homelab-postgres:5432/grafana +- postgresql://vaultwarden:PASP@homelab-postgresq:5432/vaultwarden +- postgresql://bookstack:PAS@@homelab-postgres:5432/bookstack + +## Redis DB Allocation + +- DB 0 - Authentik - DB 1 - Outline - DB 2 - Gitea - DB 3 - Nextcloud +- DB 4 - Grafana sessions + +## MariADD Connection Strings + +- mysql://bookstack:PAS@@homelab-mariadb:3306/bookstack +- mysql://nextcloud:PASS@homelab-mariadb:3306/nextcloud_mysql + +## Backup + + ./scripts/backup-databases.sh + +Backups in backups/databases/ with 7-day retention (RETENTION_DAYS). + +## Network Isolation + +Database services run on internal databases network only. Not exposed to host or proxy (except management uis). diff --git a/stacks/databases/docker-compose.yml b/stacks/databases/docker-compose.yml index 2d72d2ad..c8f41cda 100644 --- a/stacks/databases/docker-compose.yml +++ b/stacks/databases/docker-compose.yml @@ -59,10 +59,56 @@ services: labels: - "traefik.enable=false" + pgadmin: + image: dpage/pgadmin4:latest + container_name: homelab-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@homelab.local} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-changeme} + PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "True" + PGADMIN_CONFIG_LOGIN_BANNER: "HomeLab PGAdmin - Authorized Access Only" + volumes: + - pgadmin-data:/var/lib/pgadmin + networks: + - databases + - proxy + depends_on: + postgres: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.http.routers.pgadmin.rule=Host(`pgadmin.${DOMAIN:-localhost}`)" + - "traefik.http.routers.pgadmin.entrypoints=websecure" + - "traefik.http.routers.pgadmin.tls.certresolver=letsencrypt" + - "traefik.http.services.pgadmin.loadbalancer.server.port=5050" + + redis-commander: + image: rediscommander/redis-commander:latest + container_name: homelab-redis-commander + restart: unless-stopped + environment: + REDIS_HOSTS: "local:homelab-redis:6379:0:${REDIS_PASSWORD}" + HTTP_USER: ${REDIS_COMMANDER_USER:-admin} + HTTP_PASSWORD: ${REDIS_COMMANDER_PASSWORD:-changeme} + networks: + - databases + - proxy + depends_on: + redis: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.http.routers.redis-commander.rule=Host(`redis.${DOMAIN:-localhost}`)" + - "traefik.http.routers.redis-commander.entrypoints=websecure" + - "traefik.http.routers.redis-commander.tls.certresolver=letsencrypt" + - "traefik.http.services.redis-commander.loadbalancer.server.port=8081" + volumes: postgres-data: redis-data: mariadb-data: + pgadmin-data: networks: databases: diff --git a/stacks/databases/initdb/01-init-databases.sh b/stacks/databases/initdb/01-init-databases.sh old mode 100644 new mode 100755 index f0638a7c..de4867d1 --- a/stacks/databases/initdb/01-init-databases.sh +++ b/stacks/databases/initdb/01-init-databases.sh @@ -1,39 +1,126 @@ #!/bin/bash # ============================================================================= -# HomeLab PostgreSQL Init Script +# HomeLab PostgreSQL Init Script (Idempotent) # Runs on first container start. Creates per-service databases and users. +# Safe to re-run: skips already-existing users/databases. # ============================================================================= set -euo pipefail psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL -- Nextcloud - CREATE USER nextcloud WITH PASSWORD '${NEXTCLOUD_DB_PASSWORD:-changeme_nextcloud}'; - CREATE DATABASE nextcloud OWNER nextcloud ENCODING 'UTF8'; + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'nextcloud') THEN + CREATE USER nextcloud WITH PASSWORD '${NEXTCLOUD_DB_PASSWORD:-changeme_nextcloud}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'nextcloud' + \gset + \if :{?datname} + \else + CREATE DATABASE nextcloud OWNER nextcloud ENCODING 'UTF8'; + \endif GRANT ALL PRIVILEGES ON DATABASE nextcloud TO nextcloud; -- Gitea - CREATE USER gitea WITH PASSWORD '${GITEA_DB_PASSWORD:-changeme_gitea}'; - CREATE DATABASE gitea OWNER gitea ENCODING 'UTF8'; + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'gitea') THEN + CREATE USER gitea WITH PASSWORD '${GITEA_DB_PASSWORD:-changeme_gitea}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'gitea' + \gset + \if :{?datname} + \else + CREATE DATABASE gitea OWNER gitea ENCODING 'UTF8'; + \endif GRANT ALL PRIVILEGES ON DATABASE gitea TO gitea; -- Outline - CREATE USER outline WITH PASSWORD '${OUTLINE_DB_PASSWORD:-changeme_outline}'; - CREATE DATABASE outline OWNER outline ENCODING 'UTF8'; + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'outline') THEN + CREATE USER outline WITH PASSWORD '${OUTLINE_DB_PASSWORD:-changeme_outline}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'outline' + \gset + \if :{?datname} + \else + CREATE DATABASE outline OWNER outline ENCODING 'UTF8'; + \endif GRANT ALL PRIVILEGES ON DATABASE outline TO outline; - -- Outline requires uuid-ossp extension \connect outline CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; \connect postgres - -- Vaultwarden (uses SQLite by default, PostgreSQL optional) - CREATE USER vaultwarden WITH PASSWORD '${VAULTWARDEN_DB_PASSWORD:-changeme_vaultwarden}'; - CREATE DATABASE vaultwarden OWNER vaultwarden ENCODING 'UTF8'; + -- Authentik + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authentik') THEN + CREATE USER authentik WITH PASSWORD '${AUTHENTIK_DB_PASSWORD:-changeme_authentik}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'authentik' + \gset + \if :{?datname} + \else + CREATE DATABASE authentik OWNER authentik ENCODING 'UTF8'; + \endif + GRANT ALL PRIVILEGES ON DATABASE authentik TO authentik; + + -- Grafana + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'grafana') THEN + CREATE USER grafana WITH PASSWORD '${GRAFANA_DB_PASSWORD:-changeme_grafana}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'grafana' + \gset + \if :{?datname} + \else + CREATE DATABASE grafana OWNER grafana ENCODING 'UTF8'; + \endif + GRANT ALL PRIVILEGES ON DATABASE grafana TO grafana; + + -- Vaultwarden + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'vaultwarden') THEN + CREATE USER vaultwarden WITH PASSWORD '${VAULTWARDEN_DB_PASSWORD:-changeme_vaultwarden}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'vaultwarden' + \gset + \if :{?datname} + \else + CREATE DATABASE vaultwarden OWNER vaultwarden ENCODING 'UTF8'; + \endif GRANT ALL PRIVILEGES ON DATABASE vaultwarden TO vaultwarden; -- BookStack - CREATE USER bookstack WITH PASSWORD '${BOOKSTACK_DB_PASSWORD:-changeme_bookstack}'; - CREATE DATABASE bookstack OWNER bookstack ENCODING 'UTF8'; + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'bookstack') THEN + CREATE USER bookstack WITH PASSWORD '${BOOKSTACK_DB_PASSWORD:-changeme_bookstack}'; + END IF; + END + \$\$; + SELECT pg_catalog.pg_database.datname FROM pg_catalog.pg_database WHERE datname = 'bookstack' + \gset + \if :{?datname} + \else + CREATE DATABASE bookstack OWNER bookstack ENCODING 'UTF8'; + \endif GRANT ALL PRIVILEGES ON DATABASE bookstack TO bookstack; EOSQL -echo "[init-postgres] All databases created successfully" +echo "[init-postgres] All databases created successfully (idempotent)"