Skip to content

Rehearsing the v4 to v5 migration

In this tutorial, we will rehearse a v4 to v5 migration against a throwaway v5 stack, using our existing v4 deployment as a read-only source. By the end, we will have a working v5 instance populated with our real data, safely isolated from production.

The real cutover lives in the v4 to v5 migration guide. This tutorial walks the same workflow so we can shake out connectivity, data quirks, and lossy transformations before the production run.

v4 should not take writes during the rehearsal

The migrator does not write to v4, but it also does not take locks on the source. Concurrent v4 writers during extraction produce inconsistent extracts, which means rows that reference each other can land in disagreeing states in the sandbox. For the rehearsal we have three options, in decreasing order of fidelity:

  1. Stop the v4 API server for the duration of extraction, the same as the production cutover.
  2. Run the migrator against a snapshot or restored copy of the v4 database rather than the live instance.
  3. Accept inconsistent results. Useful for shaking out connectivity and lossy-transform behavior, but not for trusting row counts or referential integrity.

What we need

  • Docker and Compose v2.
  • A reachable database of a v4 instance on version 4.14.2 or later, backed by either:
    • PostgreSQL, or
    • Microsoft SQL Server.
  • The JDBC URL, username, and password for that v4 database.

Note

If v4 runs on the host, use host.docker.internal instead of localhost in the v4 JDBC URL. The migrator runs inside a container and cannot reach the host's localhost.

Downloading the compose file

We reuse the quickstart compose file for the v5 destination:

curl -O https://raw.githubusercontent.com/DependencyTrack/docs/main/docs/tutorials/docker-compose.quickstart.yml
Compose file contents
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
name: Dependency-Track

services:
  apiserver:
    image: ghcr.io/dependencytrack/apiserver:5.0.0
    depends_on:
      postgres:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 2g
    environment:
      DT_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/dtrack"
      DT_DATASOURCE_USERNAME: "dtrack"
      DT_DATASOURCE_PASSWORD: "dtrack"
    ports:
    - "127.0.0.1:8080:8080"
    volumes:
    - "apiserver-data:/data"
    restart: unless-stopped

  frontend:
    image: ghcr.io/dependencytrack/frontend:5.0.0
    environment:
      API_BASE_URL: "http://localhost:8080"
    ports:
    - "127.0.0.1:8081:8080"
    restart: unless-stopped

  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: "dtrack"
      POSTGRES_USER: "dtrack"
      POSTGRES_PASSWORD: "dtrack"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 3
    volumes:
    - "postgres-data:/var/lib/postgresql"

volumes:
  apiserver-data: {}
  postgres-data: {}

Running on the same host as v4

The compose file declares the Compose project name Dependency-Track, which Docker normalizes to dependency-track. If we also run our v4 stack on this host via Docker Compose under a project name that normalizes to the same value, the tutorial's commands target the v4 project and stop or remove v4 services. In that case, we edit the name: field at the top of the downloaded compose file to a unique value, for example dtrack-v5-sandbox, before continuing. We also adjust the network name in the migrator alias to match, for example --network=dtrack-v5-sandbox_default.

Starting only Postgres

We bring up the database service on its own:

docker compose -f docker-compose.quickstart.yml up -d postgres

We do not start apiserver yet. The v5 apiserver seeds tables on first boot that the migrator must populate from v4, so starting it before the migration would corrupt the destination.

We wait for the container to report healthy:

docker compose -f docker-compose.quickstart.yml ps postgres

The output looks like this:

1
2
NAME                       IMAGE                STATUS                   PORTS
dependency-track-postgres-1   postgres:18-alpine   Up 12 seconds (healthy)  5432/tcp

Aliasing the migrator

The migrator ships as a container image. We define a shell alias that attaches the container to the compose network so it can reach Postgres at postgres:5432. We substitute <version> with the migrator image tag matching our target v5 release.

1
alias v4-migrator='docker run --rm -it --network=dependency-track_default ghcr.io/dependencytrack/v4-migrator:<version>'

After this, the rest of the tutorial uses v4-migrator <subcommand>.

PowerShell users

PowerShell has no shell alias that forwards arguments, so we define a function instead:

1
function v4-migrator { docker run --rm -it --network=dependency-track_default ghcr.io/dependencytrack/v4-migrator:<version> @args }

Subsequent v4-migrator commands span multiple lines using \ for line continuation, which is bash and zsh syntax. In PowerShell, we replace each trailing \ with a backtick (`), or paste each command on a single line.

The network name comes from the compose project name (Dependency-Track), which Docker normalizes to lowercase and suffixes with _default. We can confirm with docker network ls:

docker network ls

Expected output:

1
2
3
4
5
NETWORK ID     NAME                         DRIVER    SCOPE
2f9b1c4d5e6a   bridge                       bridge    local
8a7b6c5d4e3f   dependency-track_default     bridge    local
1a2b3c4d5e6f   host                         host      local
0f9e8d7c6b5a   none                         null      local

Bootstrapping the v5 schema

We apply the v5 schema to the empty target:

1
2
3
4
v4-migrator bootstrap \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' \
  --target-user dtrack \
  --target-pass dtrack

Expected output:

1
2
3
Applying v5 Flyway schema up to 202605111028
...
Bootstrap complete. Flyway head = 202605111028. Run 'extract' or 'run' next.

After this, the target has the v5 schema but no rows, except in the PERMISSION table.

Verifying the empty target

We run preflight against the freshly bootstrapped target:

1
2
3
4
v4-migrator verify \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' \
  --target-user dtrack \
  --target-pass dtrack

Expected output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
== v4-migrator verify ==

[Schema]
  OK    Flyway head = 202605111028

[Row counts]
  Table                       Source      Staging           v5    Note
  (no source configured)
  LICENSE                          -            0            0
  TEAM                             -            0            0
  ...

[Probes]
  Staging schema "dt_v4_migration" not present — run extract first.

[Constraints]
  13 CHECK constraint(s) hold across 55 loaded table(s)

== verify complete ==

If the Flyway head differs or any row count is non-zero, except in the PERMISSION table, the rest of the rehearsal will not work.

Dry-running the migration

Before writing anything, we ask the migrator to print its plan. We substitute our v4 connection details into the source flags. The only difference between a PostgreSQL and a Microsoft SQL Server v4 source is the JDBC URL:

Password prompts

The commands below pass --source-pass without an argument. The migrator then prompts for the password interactively, keeping the secret out of shell history and ps output. To supply it inline, write --source-pass <v4-pass> instead.

1
2
3
4
5
v4-migrator run \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' --target-user dtrack --target-pass dtrack \
  --source-url 'jdbc:postgresql://<v4-host>:5432/<v4-db>' --source-user <v4-user> --source-pass \
  --metrics-retention-days 30 \
  --dry-run
1
2
3
4
5
v4-migrator run \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' --target-user dtrack --target-pass dtrack \
  --source-url 'jdbc:sqlserver://<v4-host>:1433;databaseName=<v4-db>' --source-user <v4-user> --source-pass \
  --metrics-retention-days 30 \
  --dry-run

The migrator requires --metrics-retention-days. v5 trims time-series metrics on a retention window that v4 does not have, so the migrator forces an explicit choice. We pass 30 to keep this walkthrough fast, since longer windows extend the load phase noticeably. For the production run we revisit this value, see the migration guide for the trade-offs.

--dry-run writes nothing to either database. The migrator prints the plan and exits.

Running the migration

We drop --dry-run and run the real extract, transform, and load:

1
2
3
4
v4-migrator run \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' --target-user dtrack --target-pass dtrack \
  --source-url 'jdbc:postgresql://<v4-host>:5432/<v4-db>' --source-user <v4-user> --source-pass \
  --metrics-retention-days 30
1
2
3
4
v4-migrator run \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' --target-user dtrack --target-pass dtrack \
  --source-url 'jdbc:sqlserver://<v4-host>:1433;databaseName=<v4-db>' --source-user <v4-user> --source-pass \
  --metrics-retention-days 30

Runtime depends on the size of our v4 dataset. The migrator prints per-table progress and a heartbeat every five seconds for long-running tables, each phase prints a completion line, and run ends with a Migration completed line that confirms success:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MetricsRetention - Metrics retention set to 30 days (cutoff = 2026-04-15T11:38:10.636499285Z)
ExtractPhase - Extracting LICENSE
ExtractPhase -   -> 811 rows in 395 ms
...
ExtractPhase - Extract phase completed: 64 table(s), 5821044 row(s) in 184302 ms
TransformPhase - Transforming LICENSE
TransformPhase -   -> 811 rows in 60 ms
...
TransformPhase - Transform phase completed: 64 table(s), 5820102 row(s) in 96118 ms
LoadPhase - Pre-creating metrics partitions for 32 day(s) from 2026-04-15 to 2026-05-16
LoadPhase - Loading LICENSE into v5
LoadProgressReporter -   -> LICENSE: 811 rows in 56 ms (14482 rows/s)
...
LoadPhase - Loading VULNERABLESOFTWARE_VULNERABILITIES into v5
LoadProgressReporter -   .. VULNERABLESOFTWARE_VULNERABILITIES: still loading after 5s (expected 2740322 rows)
LoadProgressReporter -   .. VULNERABLESOFTWARE_VULNERABILITIES: still loading after 10s (expected 2740322 rows)
LoadProgressReporter -   .. VULNERABLESOFTWARE_VULNERABILITIES: still loading after 15s (expected 2740322 rows)
...
LoadPhase - Finalizing load: re-enabling triggers and resetting identity sequences
LoadPhase - Analyzing 64 loaded table(s)
LoadPhase - Refreshing PORTFOLIOMETRICS_GLOBAL materialized view
LoadPhase - Applying v5.7.0 cleanup deletes
LoadPhase - Load phase completed: 64 table(s), 5820097 row(s) in 211740 ms
RunCommand - Migration completed: extract + transform + load finished. Run 'verify' to review row counts and probes.

Verifying the result

We run verify again. This time it reports source, staging, and v5 row counts per table, and surfaces every probe:

1
2
3
4
v4-migrator verify \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' \
  --target-user dtrack \
  --target-pass dtrack

Expected output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
== v4-migrator verify ==

[Schema]
  OK    Flyway head = 202605111028

[Row counts]
  Table                          Source      Staging           v5    Note
  LICENSE                           811          811          811
  LICENSEGROUP                        4            4            4
  LICENSEGROUP_LICENSE              131          131          131
  TEAM                               47           42           42    expected: dedup by NAME (-5)
  PROJECTS_TAGS                   12480        12462        12462    reduction (-18), see migration guide
...

[Probes]
  No probe entries.

[Constraints]
  13 CHECK constraint(s) hold across 55 loaded table(s)

== verify complete ==

Source and v5 row counts differ wherever the migrator deduplicates, drops, or rewrites rows. The migrator makes these reductions intentionally. The Note column explains each one inline, pointing to the relevant transform or to the [Probes] section, so we can confirm what accounts for every drop. The migration guide's lossy and non-obvious changes section catalogs every case, and we confirm that the reductions we see match the cases it describes.

Dropping the staging schema

With verify clean, we drop the staging schema:

1
2
3
4
v4-migrator cleanup \
  --target-url 'jdbc:postgresql://postgres:5432/dtrack' \
  --target-user dtrack \
  --target-pass dtrack

Starting the v5 stack

Now we start the apiserver and frontend against the migrated database:

docker compose -f docker-compose.quickstart.yml up -d apiserver frontend

We watch the apiserver come up:

docker compose -f docker-compose.quickstart.yml logs --follow apiserver

Once the log settles, we open the frontend at http://localhost:8081 and log in.

Use your v4 credentials, not the quickstart defaults

The migrated database carries our v4 user accounts, so the admin / admin credentials from the Quick start do not apply here. We log in with the same credentials we used in v4. The same applies to LDAP, OIDC, and API key authentication. The migration guide's lossy and non-obvious changes section lists the username rewrites the migrator applies on collisions, for example the -CONFLICT-LDAP and -CONFLICT-OIDC suffixes.

Our v4 projects, components, and findings are there. We spot-check a project we know well and confirm that its BOM, dependencies, and vulnerabilities look right.

What we just did

We migrated a real v4 dataset into a sandbox v5 instance without touching v4. The rehearsal proves three things specific to our deployment:

  • Connectivity. JDBC reaches v4 from a container.
  • Data shape. The migrator's transformations complete against our v4 row mix.
  • Lossy impact. We have seen which of our users, teams, projects, or properties the migrator deduplicates, renames, or drops.

It does not prove production cutover timing, what happens when we take v4 offline, or the post-migration credential re-entry that the production guide covers. We work through those in the migration guide.

The rehearsal is safe to repeat. Because v4 was never modified, we can tear the sandbox down and run it again whenever we like.

Tearing down

After the rehearsal, we remove the sandbox and its volumes:

docker compose -f docker-compose.quickstart.yml down -v

The compose file uses named volumes, so -v wipes the sandbox cleanly.

What's next