From d97fc60c85a230c9709c73218bbe1f6addd337b6 Mon Sep 17 00:00:00 2001 From: billbtbillb-ui Date: Fri, 15 May 2026 18:10:49 +0800 Subject: [PATCH 1/2] feat(database-stack): pgAdmin, Redis Commander, idempotent init, backup retention (#11) - Add pgAdmin4 service with Traefik reverse proxy support - Add Redis Commander service with Traefik reverse proxy support - Make PostgreSQL init script idempotent (skip existing users/databases) - Add backup retention cleanup (default 7 days, configurable via RETENTION_DAYS) - Update .env.example with pgAdmin and Redis Commander variables --- scripts/backup-databases.sh | 29 ++++++- stacks/databases/.env.example | 15 +++- stacks/databases/.gitkeep | 0 stacks/databases/docker-compose.yml | 46 +++++++++++ stacks/databases/initdb/01-init-databases.sh | 83 ++++++++++++++++---- 5 files changed, 152 insertions(+), 21 deletions(-) mode change 100644 => 100755 scripts/backup-databases.sh delete mode 100644 stacks/databases/.gitkeep mode change 100644 => 100755 stacks/databases/initdb/01-init-databases.sh diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh old mode 100644 new mode 100755 index e7cac707..f6fdb148 --- 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,25 +11,41 @@ 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" - docker exec homelab-postgres pg_dumpall -U "${POSTGRES_ROOT_USER:-postgres}" | gzip > "$file" + docker exec homelab-postgres pg_dumpall \ + -U "${POSTGRES_ROOT_USER:-postgres}" \ + | gzip > "$file" log_info "PostgreSQL backup: $file ($(du -sh "$file" | cut -f1))" } backup_redis() { log_info "Backing up Redis..." local file="$BACKUP_DIR/redis_${TIMESTAMP}.rdb" - docker exec homelab-redis redis-cli -a "${REDIS_PASSWORD}" --no-auth-warning BGSAVE + docker exec homelab-redis redis-cli \ + -a "${REDIS_PASSWORD}" --no-auth-warning BGSAVE sleep 2 docker cp homelab-redis:/data/dump.rdb "$file" log_info "Redis backup: $file" @@ -37,7 +54,10 @@ backup_redis() { backup_mariadb() { log_info "Backing up MariaDB..." local file="$BACKUP_DIR/mariadb_${TIMESTAMP}.sql.gz" - docker exec homelab-mariadb mariadb-dump --all-databases -u root -p"${MARIADB_ROOT_PASSWORD}" | gzip > "$file" + docker exec homelab-mariadb mariadb-dump \ + --all-databases \ + -u root -p"${MARIADB_ROOT_PASSWORD}" \ + | gzip > "$file" log_info "MariaDB backup: $file ($(du -sh "$file" | cut -f1))" } @@ -49,6 +69,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..b5de373b 100644 --- a/stacks/databases/.env.example +++ b/stacks/databases/.env.example @@ -1,10 +1,19 @@ # 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 +REDIS_PASSWORD=CHANGE_ME_STRONG_PASSWORD +MARIADB_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD -# Per-service passwords +# pgAdmin +PGADMIN_EMAIL=admin@homelab.local +PGADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# Redis Commander +REDIS_COMMANDER_USER=admin +REDIS_COMMANDER_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# Per-service PostgreSQL passwords NEXTCLOUD_DB_PASSWORD=CHANGE_ME GITEA_DB_PASSWORD=CHANGE_ME OUTLINE_DB_PASSWORD=CHANGE_ME diff --git a/stacks/databases/.gitkeep b/stacks/databases/.gitkeep deleted file mode 100644 index e69de29b..00000000 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..42fd1a63 --- a/stacks/databases/initdb/01-init-databases.sh +++ b/stacks/databases/initdb/01-init-databases.sh @@ -1,39 +1,94 @@ #!/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'; + -- 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)" From e632a95a92f6f4abbd6bf21b9957991ef8553f94 Mon Sep 17 00:00:00 2001 From: billbtbillb-ui Date: Fri, 15 May 2026 18:34:19 +0800 Subject: [PATCH 2/2] feat(database-stack): pgAdmin, Redis Commander, idempotent init, backup retention (#11) --- scripts/backup-databases.sh | 12 ++---- stacks/databases/.env.example | 22 ++++++----- stacks/databases/README.md | 40 ++++++++++++++++++++ stacks/databases/initdb/01-init-databases.sh | 32 ++++++++++++++++ 4 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 stacks/databases/README.md diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh index f6fdb148..15be8b07 100755 --- a/scripts/backup-databases.sh +++ b/scripts/backup-databases.sh @@ -35,17 +35,14 @@ cleanup_old_backups() { backup_postgres() { log_info "Backing up PostgreSQL..." local file="$BACKUP_DIR/postgres_${TIMESTAMP}.sql.gz" - docker exec homelab-postgres pg_dumpall \ - -U "${POSTGRES_ROOT_USER:-postgres}" \ - | gzip > "$file" + docker exec homelab-postgres pg_dumpall -U "${POSTGRES_ROOT_USER:-postgres}" | gzip > "$file" log_info "PostgreSQL backup: $file ($(du -sh "$file" | cut -f1))" } backup_redis() { log_info "Backing up Redis..." local file="$BACKUP_DIR/redis_${TIMESTAMP}.rdb" - docker exec homelab-redis redis-cli \ - -a "${REDIS_PASSWORD}" --no-auth-warning BGSAVE + docker exec homelab-redis redis-cli -a "${REDIS_PASSWORD}" --no-auth-warning BGSAVE sleep 2 docker cp homelab-redis:/data/dump.rdb "$file" log_info "Redis backup: $file" @@ -54,10 +51,7 @@ backup_redis() { backup_mariadb() { log_info "Backing up MariaDB..." local file="$BACKUP_DIR/mariadb_${TIMESTAMP}.sql.gz" - docker exec homelab-mariadb mariadb-dump \ - --all-databases \ - -u root -p"${MARIADB_ROOT_PASSWORD}" \ - | gzip > "$file" + docker exec homelab-mariadb mariadb-dump --all-databases -u root -p"${MARIADB_ROOT_PASSWORD}" | gzip > "$file" log_info "MariaDB backup: $file ($(du -sh "$file" | cut -f1))" } diff --git a/stacks/databases/.env.example b/stacks/databases/.env.example index b5de373b..dd8f51fb 100644 --- a/stacks/databases/.env.example +++ b/stacks/databases/.env.example @@ -1,21 +1,23 @@ # Database Stack DOMAIN=example.com POSTGRES_ROOT_USER=postgres -POSTGRES_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD -REDIS_PASSWORD=CHANGE_ME_STRONG_PASSWORD -MARIADB_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD +POSTGRES_ROOT_PASSWORD=CHANGEME_STRONG_PASSWORD +REDIS_PASSWORD=CHANGEME_STRONG_PASSWORD +MARIADB_ROOT_PASSWORD=CHANGEME_STRONG_PASSWORD # pgAdmin PGADMIN_EMAIL=admin@homelab.local -PGADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD +PGADMIN_PASSWORD=CHANGEME_STRONG_PASSWORD # Redis Commander REDIS_COMMANDER_USER=admin -REDIS_COMMANDER_PASSWORD=CHANGE_ME_STRONG_PASSWORD +REDIS_COMMANDER_PASSWORD=CHANGEME_STRONG_PASSWORD # Per-service PostgreSQL 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 +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/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/initdb/01-init-databases.sh b/stacks/databases/initdb/01-init-databases.sh index 42fd1a63..de4867d1 100755 --- a/stacks/databases/initdb/01-init-databases.sh +++ b/stacks/databases/initdb/01-init-databases.sh @@ -58,6 +58,38 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; \connect postgres + -- 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