> ## Documentation Index
> Fetch the complete documentation index at: https://specterops-bp-2638-jira-forge.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Upgrade PostgreSQL

> Migrate BloodHound Community Edition from PostgreSQL 16 to 18.

<img noZoom src="https://mintcdn.com/specterops-bp-2638-jira-forge/5Uew_kPPp8q6O153/assets/community-edition-pill-tag.svg?fit=max&auto=format&n=5Uew_kPPp8q6O153&q=85&s=b4180342656b77124121df5b711e48c2" alt="Applies to BloodHound CE only" width="237" height="45" data-path="assets/community-edition-pill-tag.svg" />

BloodHound Community Edition is already compatible with PostgreSQL 18. Use this guide to get a head start on the latest PostgreSQL release before it becomes the default bundled version, and to benefit from its new capabilities and performance improvements.

However, the upgrade introduces a **breaking change** to the Docker volume mount path that prevents a simple image tag update.

<Warning>
  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`      |
</Warning>

The upgrade scripts on this page automate the migration by dumping your existing data, backing up the Docker volume, updating your `docker-compose.yml`, and restoring the data into a fresh PostgreSQL 18 container.

## Prerequisites

* Docker with the Compose V2 plugin (`docker compose`, not `docker-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

<Warning>
  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.
</Warning>

The upgrade scripts perform the following steps in order:

| 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.

<Tabs>
  <Tab title="PowerShell (all platforms)">
    Save the script as `upgrade-pg.ps1` and run it with `.\upgrade-pg.ps1`.

    ```powershell theme={null}
    #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'"
    ```
  </Tab>

  <Tab title="Linux/macOS (bash)">
    Save the script as `upgrade-pg.sh`, make it executable with `chmod +x upgrade-pg.sh`, and run it with `./upgrade-pg.sh`.

    ```bash theme={null}
    #!/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'"
    ```
  </Tab>
</Tabs>

## Verify the upgrade

After the script finishes running, confirm that the database is healthy and your data is intact.

<Steps>
  <Step title="Check the container status">
    Confirm all containers are running:

    ```bash theme={null}
    docker compose ps
    ```

    The `app-db` container should show a status of `healthy`.
  </Step>

  <Step title="Confirm the PostgreSQL version">
    Confirm that PostgreSQL 18 is running:

    ```bash theme={null}
    docker compose exec app-db psql -U [POSTGRES_USER] -d [POSTGRES_DB] -c "SELECT version();"
    ```

    The output should include `PostgreSQL 18`.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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.

<Tabs>
  <Tab title="PowerShell (all platforms)">
    ```powershell theme={null}
    docker volume rm <backup-volume-name>
    Remove-Item 'C:\path\to\docker-compose.yml.pg16.bak'
    Remove-Item 'C:\path\to\pg16_backup.dump'
    ```
  </Tab>

  <Tab title="Linux/macOS (bash)">
    ```bash theme={null}
    docker volume rm <backup-volume-name>
    rm '/path/to/docker-compose.yml.pg16.bak'
    rm '/path/to/pg16_backup.dump'
    ```
  </Tab>
</Tabs>
