Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion scripts/backup-databases.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 ;;
Expand Down
29 changes: 20 additions & 9 deletions stacks/databases/.env.example
Original file line number Diff line number Diff line change
@@ -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
Empty file removed stacks/databases/.gitkeep
Empty file.
40 changes: 40 additions & 0 deletions stacks/databases/README.md
Original file line number Diff line number Diff line change
@@ -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).
46 changes: 46 additions & 0 deletions stacks/databases/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
115 changes: 101 additions & 14 deletions stacks/databases/initdb/01-init-databases.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -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)"