The PostgreSQL 18 Docker image uses a different volume mount path than version 16. Starting a PostgreSQL 18 container against an existing PostgreSQL 16 volume will fail. You must migrate your data using the scripts provided on this page.
| PostgreSQL version | Volume mount path |
|---|---|
| 16 (and earlier) | /var/lib/postgresql/data |
| 18 (and later) | /var/lib/postgresql |
docker-compose.yml, and restoring the data into a fresh PostgreSQL 18 container.
Prerequisites
- Docker with the Compose V2 plugin (
docker compose, notdocker-compose) - Sufficient free disk space for the database dump file and a volume backup
- PowerShell 5.1 or later (all platforms), or bash (Linux/macOS)
- Your BloodHound CE installation must be accessible and running before you begin
Before you begin
Back up your data before running the upgrade script. The scripts automatically create a copy of your PostgreSQL Docker volume, but you should verify that your data is intact independently before and after the migration.
| Step | What it does |
|---|---|
| 1. Stop app, keep database running | Stops the BloodHound application container while leaving PostgreSQL 16 running so the database can be dumped cleanly |
| 2. Dump the database | Exports the PostgreSQL 16 database to a compressed dump file on your host |
| 3. Stop all containers | Brings down all containers before modifying the volume |
| 4. Back up the data volume | Copies the PostgreSQL 16 Docker volume to a backup volume for safety |
5. Update docker-compose.yml | Changes the image tag to postgres:18 and updates the volume mount path; saves a backup of the original compose file |
| 6. Start PostgreSQL 18 and restore | Removes the old volume, starts PostgreSQL 18, and restores the database from the dump file |
| 7. Start the full stack | Brings up all BloodHound CE containers |
Run the upgrade script
Save the following script to a file and run it.- PowerShell (all platforms)
- Linux/macOS (bash)
Save the script as
upgrade-pg.ps1 and run it with .\upgrade-pg.ps1.#Requires -Version 5.1
<#
.SYNOPSIS
Migrates BloodHound Community Edition PostgreSQL from 16 to 18.
.DESCRIPTION
Walks through dumping the PG 16 database, backing up the Docker volume,
upgrading to PG 18, and restoring the data. Requires Docker with the
Compose V2 plugin.
#>
$ErrorActionPreference = "Stop"
function Write-Step { param([string]$Msg) Write-Host "`n=== $Msg ===" -ForegroundColor Cyan }
function Write-Ok { param([string]$Msg) Write-Host $Msg -ForegroundColor Green }
function Write-Warn { param([string]$Msg) Write-Host $Msg -ForegroundColor Yellow }
# ── 1. Gather inputs ────────────────────────────────────────────────────────
Write-Step "BloodHound PostgreSQL 16 -> 18 Migration"
$defaultDir = Join-Path $HOME ".config" "bloodhound"
$composeDir = Read-Host "Enter the directory containing docker-compose.yml (default: $defaultDir)"
if ([string]::IsNullOrWhiteSpace($composeDir)) { $composeDir = $defaultDir }
$composeDir = [System.IO.Path]::GetFullPath($composeDir.Replace("~", $HOME))
$composeFile = Join-Path $composeDir "docker-compose.yml"
if (-not (Test-Path $composeFile)) {
Write-Error "docker-compose.yml not found at $composeFile"; exit 1
}
$defaultDump = Join-Path $composeDir "pg16_backup.dump"
$dumpPath = Read-Host "Enter path for the database dump file (default: $defaultDump)"
if ([string]::IsNullOrWhiteSpace($dumpPath)) { $dumpPath = $defaultDump }
$dumpPath = [System.IO.Path]::GetFullPath($dumpPath.Replace("~", $HOME))
# ── 2. Read credentials (.env then defaults) ────────────────────────────────
$pgUser = "bloodhound"; $pgDb = "bloodhound"
$envFile = Join-Path $composeDir ".env"
if (Test-Path $envFile) {
Write-Host "Reading overrides from $envFile ..."
Get-Content $envFile | ForEach-Object {
if ($_ -match '^\s*POSTGRES_USER\s*=\s*(.+)$') { $pgUser = $Matches[1].Trim() }
if ($_ -match '^\s*POSTGRES_DB\s*=\s*(.+)$') { $pgDb = $Matches[1].Trim() }
}
}
Write-Host "Credentials: user=$pgUser db=$pgDb"
# ── 3. Resolve project and volume names via Docker Compose ───────────────────
$dcBase = @("-f", $composeFile, "--project-directory", $composeDir)
$projectName = (docker compose @dcBase config --format json | ConvertFrom-Json).name
if ([string]::IsNullOrWhiteSpace($projectName)) {
Write-Error "Could not determine Compose project name."; exit 1
}
$declaredVols = @(docker compose @dcBase config --volumes)
$pgVolDecl = $declaredVols | Where-Object { $_ -match 'postgres-data' } | Select-Object -First 1
if (-not $pgVolDecl) {
Write-Error "No 'postgres-data' volume declared in compose config."; exit 1
}
$volumeName = "${projectName}_${pgVolDecl}"
$volExists = docker volume ls --format "{{.Name}}" | Where-Object { $_ -eq $volumeName }
if (-not $volExists) {
Write-Error "Docker volume '$volumeName' not found. Is BloodHound installed?"
exit 1
}
Write-Host "Project: $projectName Volume: $volumeName"
# ── 4. Confirm ──────────────────────────────────────────────────────────────
Write-Warn "`nThis script will:"
Write-Host " 1. Stop the BloodHound app (keep PG 16 running)"
Write-Host " 2. Dump the PG 16 database to: $dumpPath"
Write-Host " 3. Stop all containers"
Write-Host " 4. Create a backup copy of the PostgreSQL data volume"
Write-Host " 5. Update docker-compose.yml to use PostgreSQL 18"
Write-Host " 6. Start PG 18 and restore the database"
Write-Host " 7. Start the full BloodHound stack"
$confirm = Read-Host "`nProceed? (y/N)"
if ($confirm -notin @('y','Y')) { Write-Host "Cancelled."; exit 0 }
# ── Helper: run docker compose with project context ──────────────────────────
function Invoke-DC {
$dcArgs = @("-f", $composeFile, "--project-directory", $composeDir) + $args
& docker compose @dcArgs
if ($LASTEXITCODE -ne 0) { throw "docker compose failed (exit $LASTEXITCODE)" }
}
# ── 5. Stop app, keep PG 16 alive ───────────────────────────────────────────
Write-Step "Stopping BloodHound application container"
Invoke-DC stop bloodhound
Write-Ok "BloodHound app stopped."
Write-Step "Ensuring PostgreSQL 16 is running"
Invoke-DC start app-db
Start-Sleep -Seconds 5
# ── 6. Dump database ────────────────────────────────────────────────────────
Write-Step "Dumping PostgreSQL 16 database"
Invoke-DC exec app-db pg_dump -U $pgUser -d $pgDb -Fc -Z 9 -f /tmp/pg16_backup.dump
Invoke-DC cp app-db:/tmp/pg16_backup.dump $dumpPath
$dumpSize = (Get-Item $dumpPath).Length
Write-Ok "Database dumped ($dumpSize bytes) -> $dumpPath"
# ── 7. Stop everything ──────────────────────────────────────────────────────
Write-Step "Stopping all containers"
Invoke-DC down
Write-Ok "All containers stopped."
# ── 8. Copy the volume ──────────────────────────────────────────────────────
Write-Step "Backing up PostgreSQL data volume"
$backupVol = "${volumeName}-pg16-backup"
docker volume create $backupVol | Out-Null
docker run --rm -v "${volumeName}:/source:ro" -v "${backupVol}:/backup" `
alpine sh -c "cp -a /source/. /backup/"
Write-Ok "Volume copied to: $backupVol"
# ── 9. Update docker-compose.yml ────────────────────────────────────────────
Write-Step "Updating docker-compose.yml to PostgreSQL 18"
$bakFile = Join-Path $composeDir "docker-compose.yml.pg16.bak"
Copy-Item $composeFile $bakFile
Write-Host "Compose file backed up to: $bakFile"
$content = Get-Content $composeFile -Raw
$updated = $content -replace 'image:\s*docker\.io/library/postgres:16', 'image: docker.io/library/postgres:18'
if ($updated -eq $content) { Write-Error "Could not find postgres:16 image reference in compose file"; exit 1 }
# PG 18+ expects the mount at /var/lib/postgresql (not /var/lib/postgresql/data).
# It manages version-specific subdirectories under that path automatically.
$beforeMount = $updated
$updated = $updated -replace 'postgres-data:/var/lib/postgresql/data', 'postgres-data:/var/lib/postgresql'
if ($updated -eq $beforeMount) { Write-Error "Could not find postgres-data:/var/lib/postgresql/data volume mount in compose file"; exit 1 }
[System.IO.File]::WriteAllText($composeFile, $updated)
Write-Ok "docker-compose.yml updated (image + volume mount path)."
# ── 10. Remove old volume, start PG 18 ──────────────────────────────────────
Write-Step "Removing old PostgreSQL data volume"
docker volume rm $volumeName | Out-Null
Write-Ok "Old volume removed (backup preserved at $backupVol)."
Write-Step "Starting PostgreSQL 18"
Invoke-DC up -d app-db
# Resolve the actual container name for app-db from Compose
$appDbContainer = (docker compose @dcBase ps --format "{{.Name}}" app-db).Trim()
if ([string]::IsNullOrWhiteSpace($appDbContainer)) {
Write-Error "Could not determine container name for app-db service."; exit 1
}
Write-Host "Waiting for PostgreSQL 18 to become healthy ($appDbContainer)..."
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 2
$health = docker inspect --format "{{.State.Health.Status}}" $appDbContainer 2>$null
if ($health -eq "healthy") { break }
}
if ($health -ne "healthy") { Write-Error "Container '$appDbContainer' is not healthy (status: $health). Aborting migration."; exit 1 }
# ── 11. Restore database ────────────────────────────────────────────────────
Write-Step "Restoring database into PostgreSQL 18"
Invoke-DC cp $dumpPath app-db:/tmp/pg16_backup.dump
Invoke-DC exec app-db pg_restore -U $pgUser -d $pgDb --clean --if-exists /tmp/pg16_backup.dump
Write-Ok "Database restored."
# ── 12. Start full stack ─────────────────────────────────────────────────────
Write-Step "Starting full BloodHound stack"
Invoke-DC up -d
Write-Ok "BloodHound stack is starting."
# ── Done ─────────────────────────────────────────────────────────────────────
Write-Step "Migration Complete"
Write-Ok "PostgreSQL upgraded from 16 to 18."
Write-Host " Database dump : $dumpPath"
Write-Host " Volume backup : $backupVol"
Write-Host " Compose backup: $bakFile"
Write-Warn "`nOnce verified, you can clean up with:"
Write-Host " docker volume rm $backupVol"
Write-Host " Remove-Item '$bakFile'"
Write-Host " Remove-Item '$dumpPath'"
Save the script as
upgrade-pg.sh, make it executable with chmod +x upgrade-pg.sh, and run it with ./upgrade-pg.sh.#!/usr/bin/env bash
# Migrates BloodHound Community Edition PostgreSQL from 16 to 18.
# Requires Docker with the Compose V2 plugin.
set -euo pipefail
# ── Helpers ──────────────────────────────────────────────────────────────────
step() { printf '\n\033[36m=== %s ===\033[0m\n' "$1"; }
ok() { printf '\033[32m%s\033[0m\n' "$1"; }
warn() { printf '\033[33m%s\033[0m\n' "$1"; }
die() { printf '\033[31mError: %s\033[0m\n' "$1" >&2; exit 1; }
dc() { docker compose -f "$COMPOSE_FILE" --project-directory "$COMPOSE_DIR" "$@"; }
# ── 1. Gather inputs ────────────────────────────────────────────────────────
step "BloodHound PostgreSQL 16 -> 18 Migration"
DEFAULT_DIR="$HOME/.config/bloodhound"
read -rp "Enter the directory containing docker-compose.yml (default: $DEFAULT_DIR): " COMPOSE_DIR
COMPOSE_DIR="${COMPOSE_DIR:-$DEFAULT_DIR}"
COMPOSE_DIR="${COMPOSE_DIR/#\~/$HOME}"
COMPOSE_DIR="$(cd "$COMPOSE_DIR" && pwd)"
COMPOSE_FILE="$COMPOSE_DIR/docker-compose.yml"
[ -f "$COMPOSE_FILE" ] || die "docker-compose.yml not found at $COMPOSE_FILE"
DEFAULT_DUMP="$COMPOSE_DIR/pg16_backup.dump"
read -rp "Enter path for the database dump file (default: $DEFAULT_DUMP): " DUMP_PATH
DUMP_PATH="${DUMP_PATH:-$DEFAULT_DUMP}"
DUMP_PATH="${DUMP_PATH/#\~/$HOME}"
# ── 2. Read credentials (.env then defaults) ────────────────────────────────
PG_USER="bloodhound"
PG_DB="bloodhound"
ENV_FILE="$COMPOSE_DIR/.env"
if [ -f "$ENV_FILE" ]; then
echo "Reading overrides from $ENV_FILE ..."
_val="$(grep -E '^\s*POSTGRES_USER\s*=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | xargs || true)" && [ -n "$_val" ] && PG_USER="$_val"
_val="$(grep -E '^\s*POSTGRES_DB\s*=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | xargs || true)" && [ -n "$_val" ] && PG_DB="$_val"
fi
echo "Credentials: user=$PG_USER db=$PG_DB"
# ── 3. Resolve project and volume names via Docker Compose ───────────────────
PROJECT_NAME="$(docker compose -f "$COMPOSE_FILE" --project-directory "$COMPOSE_DIR" \
config --format json | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])")"
[ -n "$PROJECT_NAME" ] || die "Could not determine Compose project name."
PG_VOL_DECL="$(docker compose -f "$COMPOSE_FILE" --project-directory "$COMPOSE_DIR" \
config --volumes | grep 'postgres-data' | head -n1)"
[ -n "$PG_VOL_DECL" ] || die "No 'postgres-data' volume declared in compose config."
VOLUME_NAME="${PROJECT_NAME}_${PG_VOL_DECL}"
docker volume ls --format '{{.Name}}' | grep -qx "$VOLUME_NAME" \
|| die "Docker volume '$VOLUME_NAME' not found. Is BloodHound installed?"
echo "Project: $PROJECT_NAME Volume: $VOLUME_NAME"
# ── 4. Confirm ──────────────────────────────────────────────────────────────
warn ""
warn "This script will:"
echo " 1. Stop the BloodHound app (keep PG 16 running)"
echo " 2. Dump the PG 16 database to: $DUMP_PATH"
echo " 3. Stop all containers"
echo " 4. Create a backup copy of the PostgreSQL data volume"
echo " 5. Update docker-compose.yml to use PostgreSQL 18"
echo " 6. Start PG 18 and restore the database"
echo " 7. Start the full BloodHound stack"
read -rp $'\nProceed? (y/N): ' CONFIRM
[[ "$CONFIRM" =~ ^[yY]$ ]] || { echo "Cancelled."; exit 0; }
# ── 5. Stop app, keep PG 16 alive ───────────────────────────────────────────
step "Stopping BloodHound application container"
dc stop bloodhound
ok "BloodHound app stopped."
step "Ensuring PostgreSQL 16 is running"
dc start app-db
sleep 5
# ── 6. Dump database ────────────────────────────────────────────────────────
step "Dumping PostgreSQL 16 database"
dc exec app-db pg_dump -U "$PG_USER" -d "$PG_DB" -Fc -Z 9 -f /tmp/pg16_backup.dump
dc cp app-db:/tmp/pg16_backup.dump "$DUMP_PATH"
DUMP_SIZE="$(stat -f%z "$DUMP_PATH" 2>/dev/null || stat -c%s "$DUMP_PATH")"
ok "Database dumped ($DUMP_SIZE bytes) -> $DUMP_PATH"
# ── 7. Stop everything ──────────────────────────────────────────────────────
step "Stopping all containers"
dc down
ok "All containers stopped."
# ── 8. Copy the volume ──────────────────────────────────────────────────────
step "Backing up PostgreSQL data volume"
BACKUP_VOL="${VOLUME_NAME}-pg16-backup"
docker volume create "$BACKUP_VOL" > /dev/null
docker run --rm -v "${VOLUME_NAME}:/source:ro" -v "${BACKUP_VOL}:/backup" \
alpine sh -c "cp -a /source/. /backup/"
ok "Volume copied to: $BACKUP_VOL"
# ── 9. Update docker-compose.yml ────────────────────────────────────────────
step "Updating docker-compose.yml to PostgreSQL 18"
BAK_FILE="$COMPOSE_DIR/docker-compose.yml.pg16.bak"
cp "$COMPOSE_FILE" "$BAK_FILE"
echo "Compose file backed up to: $BAK_FILE"
# Replace image tag
if ! grep -q 'docker.io/library/postgres:16' "$COMPOSE_FILE"; then
die "Could not find postgres:16 image reference in compose file"
fi
sed -i.tmp 's|image: *docker\.io/library/postgres:16|image: docker.io/library/postgres:18|' "$COMPOSE_FILE"
# PG 18+ expects the mount at /var/lib/postgresql (not /var/lib/postgresql/data).
if ! grep -q 'postgres-data:/var/lib/postgresql/data' "$BAK_FILE"; then
die "Could not find postgres-data:/var/lib/postgresql/data volume mount in compose file"
fi
sed -i.tmp 's|postgres-data:/var/lib/postgresql/data|postgres-data:/var/lib/postgresql|' "$COMPOSE_FILE"
rm -f "${COMPOSE_FILE}.tmp"
ok "docker-compose.yml updated (image + volume mount path)."
# ── 10. Remove old volume, start PG 18 ──────────────────────────────────────
step "Removing old PostgreSQL data volume"
docker volume rm "$VOLUME_NAME" > /dev/null
ok "Old volume removed (backup preserved at $BACKUP_VOL)."
step "Starting PostgreSQL 18"
dc up -d app-db
# Resolve the actual container name for app-db from Compose
APP_DB_CONTAINER="$(docker compose -f "$COMPOSE_FILE" --project-directory "$COMPOSE_DIR" \
ps --format '{{.Name}}' app-db | head -n1 | xargs)"
[ -n "$APP_DB_CONTAINER" ] || die "Could not determine container name for app-db service."
echo "Waiting for PostgreSQL 18 to become healthy ($APP_DB_CONTAINER)..."
for i in $(seq 1 30); do
sleep 2
HEALTH="$(docker inspect --format '{{.State.Health.Status}}' "$APP_DB_CONTAINER" 2>/dev/null || true)"
[ "$HEALTH" = "healthy" ] && break
done
[ "$HEALTH" = "healthy" ] || die "Container '$APP_DB_CONTAINER' is not healthy (status: $HEALTH). Aborting migration."
# ── 11. Restore database ────────────────────────────────────────────────────
step "Restoring database into PostgreSQL 18"
dc cp "$DUMP_PATH" app-db:/tmp/pg16_backup.dump
dc exec app-db pg_restore -U "$PG_USER" -d "$PG_DB" --clean --if-exists /tmp/pg16_backup.dump
ok "Database restored."
# ── 12. Start full stack ─────────────────────────────────────────────────────
step "Starting full BloodHound stack"
dc up -d
ok "BloodHound stack is starting."
# ── Done ─────────────────────────────────────────────────────────────────────
step "Migration Complete"
ok "PostgreSQL upgraded from 16 to 18."
echo " Database dump : $DUMP_PATH"
echo " Volume backup : $BACKUP_VOL"
echo " Compose backup: $BAK_FILE"
warn ""
warn "Once verified, you can clean up with:"
echo " docker volume rm $BACKUP_VOL"
echo " rm '$BAK_FILE'"
echo " rm '$DUMP_PATH'"
Verify the upgrade
After the script finishes running, confirm that the database is healthy and your data is intact.1
Check the container status
Confirm all containers are running:The
docker compose ps
app-db container should show a status of healthy.2
Confirm the PostgreSQL version
Confirm that PostgreSQL 18 is running:The output should include
docker compose exec app-db psql -U [POSTGRES_USER] -d [POSTGRES_DB] -c "SELECT version();"
PostgreSQL 18.3
Log in to BloodHound CE
Open your browser and navigate to BloodHound CE (default:
http://127.0.0.1:8080). Log in and confirm that your data is accessible.Clean up
After you have verified that the upgrade was successful and your data is intact, remove the temporary files and backup volume created during the migration. Replace the placeholder values with the actual paths and volume name printed by the script.- PowerShell (all platforms)
- Linux/macOS (bash)
docker volume rm <backup-volume-name>
Remove-Item 'C:\path\to\docker-compose.yml.pg16.bak'
Remove-Item 'C:\path\to\pg16_backup.dump'
docker volume rm <backup-volume-name>
rm '/path/to/docker-compose.yml.pg16.bak'
rm '/path/to/pg16_backup.dump'