migration-doctor-snapshot

Free, open-source, read-only snapshot of Jira Data Center configuration, for the Migration Doctor app on Jira Cloud by Nox Development.

You run one script on a machine that can reach your Jira Data Center. It reads configuration through the Jira REST API (HTTP GET only) and writes one JSON file. You then upload that file into Migration Doctor on your own Jira Cloud site, which compares it with the migrated configuration and reports what JCMA left broken: filters that reference DC usernames, filters and dashboards that did not arrive, empty workflow conditions and validators, permission schemes that point at missing groups.

Two equivalent editions, same output:

Script Runs on Needs
snapshot.ps1 Windows PowerShell 5.1, PowerShell 7+ (Windows, Linux, macOS) nothing beyond built-in cmdlets and .NET
snapshot.sh bash 3.2+ (Linux, macOS, Git Bash, WSL) curl, jq 1.6+

The snapshot is useful on its own as a record of the DC configuration before a migration.

Download (version 1.0.0)

Free and open source (MIT). Download the zip, unpack it and follow Usage below. The scripts only read from your Jira Data Center and send nothing anywhere.

FileSizeWhat it is
migration-doctor-snapshot.zip58.8 KBEverything below in one archive: both scripts, SQL queries, schema, README, license.
snapshot.ps151.6 KBPowerShell edition (Windows PowerShell 5.1, PowerShell 7+).
snapshot.sh41.9 KBbash edition (Linux, macOS, Git Bash, WSL; needs curl and jq).
sql/Read-only SQL queries for PostgreSQL, MySQL and SQL Server.
schema/snapshot.schema.json7.7 KBJSON Schema of the snapshot file.
README.md24.5 KBThis page as Markdown.
CHANGELOG.md1.3 KBChanges by version.
LICENSE1.0 KBMIT license.
SHA256SUMSSHA-256 checksums of every file.

After downloading snapshot.ps1 on Windows, run Unblock-File .\snapshot.ps1 once. To check a download: Get-FileHash (Windows) or shasum -a 256 (macOS, Linux) and compare with SHA256SUMS.

Contents

What it collects and what it never collects

Collects (configuration only):

Section Fields Source
Server Jira version, base URL, server title REST
Users username, user key, display name, e-mail, active flag (active and inactive users) REST (SQL adds users REST cannot list and e-mails hidden by e-mail visibility)
Groups group names REST (capped, see below) or SQL (complete)
Projects id, key, name, project type (archived projects included) REST
Fields id, name, custom flag, custom field type key REST
Statuses id, name REST
Permission schemes grants (permission + holder type and parameter), projects using the scheme REST
Workflows names; with SQL: transitions (from, to), conditions, validators, post functions, providing app REST + SQL
Boards (optional) id, name, type, board filter id REST (Jira Software agile API)
Filters id, name, description, JQL, owner username, share permissions REST (only filters this account can see) or SQL (all, including private)
Dashboards id, name; with SQL: owner, share permissions, gadgets with their filter REST + SQL

Never collects: issues, issue fields or values, comments, attachments, worklogs, issue history, passwords, tokens, API keys, avatars, application properties, licences, audit logs, or anything from other Atlassian products.

Never does: any request other than HTTP GET; any change to Jira; any network call to anything other than the Jira base URL you give it. It does not follow HTTP redirects, so credentials are never sent to another host. It does not run SQL: the optional SQL queries are run by you, with your own database client and a read-only database account.

Privacy

The snapshot file contains personal data: usernames, display names and e-mail addresses of all users (active and inactive), plus filter names and JQL, which can contain names of people and projects.

Requirements

Usage

Credentials come only from environment variables. The scripts reject any secret passed as an argument.

Variable Meaning
JIRA_PAT personal access token, sent as Authorization: Bearer (preferred)
JIRA_USER, JIRA_PASSWORD basic authentication, used when JIRA_PAT is not set

PowerShell

# PowerShell 7: prompt without echo
$env:JIRA_PAT = Read-Host 'Jira personal access token' -MaskInput
# Windows PowerShell 5.1: prompt without echo
$env:JIRA_PAT = [System.Net.NetworkCredential]::new('', (Read-Host 'Jira personal access token' -AsSecureString)).Password

.\snapshot.ps1 -BaseUrl https://jira.example.com
.\snapshot.ps1 -BaseUrl https://example.com/jira -Out C:\temp\acme-snapshot.json -Projects ACME,OPS
.\snapshot.ps1 -BaseUrl https://jira.example.com -SqlExportDir .\export

Remove-Item Env:\JIRA_PAT

If the execution policy blocks the script: powershell -ExecutionPolicy Bypass -File .\snapshot.ps1 -BaseUrl ..., or Unblock-File .\snapshot.ps1 after downloading.

Parameter Default Meaning
-BaseUrl required Jira base URL including the context path, e.g. https://example.com/jira
-Out migration-doctor-snapshot-<date>.json output file
-Projects all limit projects, their permission schemes, workflows and boards to these keys
-SqlExportDir none directory with the results of the SQL exports
-FilterScanGap 100 without SQL exports, probe filter IDs (see Filters); 0 disables
-DelayMs 100 pause between requests
-MaxRetries 5 retries for HTTP 429, 502, 503, 504 and network errors
-NoBoards off skip Jira Software boards

bash

read -rsp 'Jira personal access token: ' JIRA_PAT && export JIRA_PAT && echo

./snapshot.sh --base-url https://jira.example.com
./snapshot.sh --base-url https://example.com/jira --out /tmp/acme-snapshot.json --projects ACME,OPS
./snapshot.sh --base-url https://jira.example.com --sql-export-dir ./export

unset JIRA_PAT

Options are the same as in PowerShell: --base-url, --out, --projects, --sql-export-dir, --filter-scan-gap, --delay-ms, --max-retries, --no-boards, --help, --version.

What you see

Progress goes to the console (stderr), one step per section, then a summary:

Snapshot written: migration-doctor-snapshot-2026-10-11.json (16353 bytes, 20 GET requests, 14s)

  users               6
  groups              6
  projects            4
  ...
  Coverage: users=full, groups=full, ..., filters=full, dashboards=full, workflows=full, ...

Exit codes: 0 success (warnings possible), 1 the run failed (HTTP error after retries, authentication, permissions, invalid SQL export), 2 usage error.

--projects

Limits projects, the permission schemes used by those projects, the workflows in their workflow schemes, and their boards. Users, groups, fields, statuses, filters and dashboards are global in Jira and are always exported in full, because filters and dashboards often span projects.

Read-only SQL exports

Jira Data Center's REST API cannot provide three things Migration Doctor checks:

Gap in DC REST (8.20 to 11.x) Effect without SQL SQL export
No endpoint lists all filters; an admin cannot read other users' private filters only filters this account can see (coverage.filters = rest-partial) filters.sql + share-permissions.sql
Dashboards REST returns only id and name no owner, sharing or gadgets (coverage.dashboards = rest-no-owner) dashboards.sql + dashboard-gadgets.sql + share-permissions.sql
No endpoint exposes workflow transitions, conditions, validators or post functions workflows with empty transitions (coverage.workflows = none) workflows.sql (+ plugins.sql)
The group list is capped by jira.ajax.autocomplete.limit possibly incomplete (coverage.groups = rest-partial) groups.sql
User listing differs by version and hides e-mails under some e-mail visibility settings e-mails may be missing users.sql

So the SQL exports are a normal part of a complete snapshot, not an emergency fallback. They are plain SELECT statements in sql/, one file per section and database:

Database Directory Client Tested in CI on
PostgreSQL sql/postgresql psql PostgreSQL 16
MySQL 5.7.8+ / 8.x sql/mysql mysql MySQL 8.0
Microsoft SQL Server sql/sqlserver sqlcmd SQL Server 2022
Oracle not provided yet

You run them, not the script. Use a read-only database account (or a read replica). Each query returns one JSON object per line; save each result as <name>.jsonl in one directory and pass that directory with --sql-export-dir / -SqlExportDir. The script reads only these file names: users, groups, filters, share-permissions, dashboards, dashboard-gadgets, workflows, plugins (each .jsonl). All are optional, but filters and dashboards need share-permissions from the same run, and dashboards needs dashboard-gadgets.

PostgreSQL:

mkdir -p export
for f in sql/postgresql/*.sql; do
  psql -X -q -A -t -v ON_ERROR_STOP=1 -h DBHOST -U readonly -d jiradb -f "$f" -o "export/$(basename "$f" .sql).jsonl"
done
# Tables in a non-default schema: export PGOPTIONS='-c search_path=your_schema' first.

MySQL:

mkdir -p export
for f in sql/mysql/*.sql; do
  mysql --batch --raw --skip-column-names --default-character-set=utf8mb4 -h DBHOST -u readonly -p"$DBPASS" jiradb < "$f" > "export/$(basename "$f" .sql).jsonl"
done

SQL Server (PowerShell; JIRA_SCHEMA is the schema of the Jira tables, usually jiraschema; -y 0 stops sqlcmd from truncating long values such as workflow descriptors):

New-Item -ItemType Directory -Force export | Out-Null
Get-ChildItem sql\sqlserver\*.sql | ForEach-Object {
  sqlcmd -S DBHOST -d jiradb -E -v JIRA_SCHEMA=jiraschema -y 0 -f 65001 -i $_.FullName -o "export\$($_.BaseName).jsonl"
}

In SQL Server Management Studio, enable Query, SQLCMD Mode before running a file (the files use the $(JIRA_SCHEMA) sqlcmd variable), and save results to a file; the sqlcmd loop above is simpler and does not truncate long values.

Notes:

Filters without SQL

Without filters.jsonl the script collects your favourite filters, the filters of the boards it can read, and probes filter IDs upwards from 10000 (GET /rest/api/2/filter/{id}) until it sees --filter-scan-gap consecutive IDs that are missing or not visible, after the highest ID it already knows. This finds every filter shared with or owned by the account running the script, including other users' filters shared with a group or project you belong to. Other users' private filters are never visible through REST, not even to a system administrator: use the SQL export for them.

Output format

One UTF-8 JSON file that validates against schema/snapshot.schema.json (JSON Schema 2020-12, a copy of the schema the Migration Doctor app validates uploads with):

{
  "format": "migration-doctor-snapshot",
  "schemaVersion": 1,
  "createdAt": "2026-10-11T09:00:00Z",
  "generator": { "name": "migration-doctor-snapshot", "version": "1.0.0", "runtime": "bash" },
  "source": { "product": "jira", "deployment": "datacenter", "version": "9.12.3",
              "baseUrl": "https://jira.example.com", "serverTitle": "Acme Jira" },
  "coverage": { "users": "full", "groups": "full", "filters": "rest-partial",
                "dashboards": "rest-no-owner", "workflows": "none", "...": "..." },
  "users": [], "groups": [], "projects": [], "fields": [], "statuses": [],
  "filters": [], "dashboards": [], "workflows": [], "permissionSchemes": [], "boards": []
}

coverage tells the app which sections are complete (full), partial because of REST limits (rest-partial), missing owners (rest-no-owner) or missing entirely (none), so it can avoid false alarms. Arrays are sorted (by name, key or numeric id) so two snapshots of the same instance diff cleanly. source.deployment is always datacenter: serverInfo.deploymentType reports Server on every self-managed instance, and Server licences ended in February 2024.

Fields beyond the schema's documented properties (allowed by it) that the script adds: workflows[].transitionsAvailable (false when no descriptor was available), transitions[].looped (true for a transition that returns to the status it starts from; to is then empty), rules[].moduleKey (the full.module.key from the descriptor) and generator.runtime.

REST endpoints used

All requests are GET, sequential, with a pause between them (--delay-ms), and retried with exponential backoff on HTTP 429 (honouring Retry-After), 502, 503, 504 and network errors.

Purpose Endpoint Paging Reference
Version, base URL, title /rest/api/2/serverInfo none 9.12
Users (10.3+, 11.x) /rest/api/2/user/list?maxResults=1000&cursor= cursor (nextCursor, isLast) 11.x user group (also in the 10.3 spec)
Users (8.20 to 10.2) /rest/api/2/user/search?username=.&includeActive=true&includeInactive=true&startAt=&maxResults=1000 startAt by rows returned, until an empty page 9.12
Groups /rest/api/2/groups/picker?query=&maxResults=100000 none (capped, total checked) 9.12
Projects /rest/api/2/project?includeArchived=true none 9.12
Fields /rest/api/2/field none 9.12
Statuses /rest/api/2/status none 9.12
Permission schemes with grants /rest/api/2/permissionscheme?expand=permissions,user,group,projectRole,field none 9.12
Scheme of each project /rest/api/2/project/{key}/permissionscheme one call per project 9.12
Workflow names /rest/api/2/workflow none 9.12
Workflows of a project (--projects only) /rest/api/2/project/{key}/workflowscheme one call per project 9.12
Boards /rest/agile/1.0/board?startAt=&maxResults=50 (&projectKeyOrId= with --projects) startAt, isLast Jira Software 9.12
Board filter /rest/agile/1.0/board/{id}/configuration one call per board Jira Software 9.12
Favourite filters /rest/api/2/filter/favourite none 9.12
Filter by id (without SQL) /rest/api/2/filter/{id} ID probing 9.12
Dashboards (without SQL) /rest/api/2/dashboard?startAt=&maxResults= startAt in multiples of the returned maxResults, until total 9.12

Request count for a typical instance: about 15 + one per project (two with --projects) + one per board + the filter probing range when SQL exports are not used.

Endpoints deliberately not used: /rest/api/2/filter/search (does not exist on DC), undocumented internal endpoints such as the workflow designer REST resource, the workflow XML export page (/secure/admin/workflows/ViewWorkflowXml.jspa, a web action that can require websudo), and the Universal Plugin Manager REST API.

Jira versions

8.20 9.x (primary) 10.x 11.x
Personal access tokens yes (8.14+) yes yes yes
Users user/search; 8.20.0 to 8.20.6 return at most 100 rows per call even when 1000 are requested (fixed in 8.20.7). The script pages by rows actually returned, so it still gets everyone user/search, 1000 per call 10.0 to 10.2: user/search, 1000 per call. 10.3+ adds user/list, which the script prefers user/list (cursor). user/search reaches only the first 100 users
Agile boards /rest/agile/1.0 same same (Jira Software and Jira Core REST published together from 10.0) same
Filters, dashboards, workflow rules via REST not available not available not available not available
Database queries same tables same same same

The script detects user/list by calling it first and falls back to user/search on HTTP 404. If a 10.x or 11.x instance without user/list returns exactly 100 users through user/search, the script warns and you should add users.jsonl from SQL. username=. as a match-all query is the long-standing community convention for listing users on DC; it is not formally documented by Atlassian.

Jira 8.20 reached end of support; 9.x and later are the realistic migration sources. The script was tested against recorded 9.12 and 11.0 responses (see tests), not against live 8.20 or 10.x servers; reports from real instances are welcome.

Troubleshooting

Symptom Cause and fix
HTTP 401 ... Authentication failed Wrong or expired token, or JIRA_PAT not exported. With basic auth, check JIRA_USER/JIRA_PASSWORD.
HTTP 403 on the first calls Basic auth hit the login CAPTCHA after failed logins: log in once in the browser, or use a personal access token. Also check that the account is a Jira System Administrator.
HTTP 401 or 403 on workflows or permission schemes only The account is not a system administrator. Some instances also require a websudo (secure administrator) session for admin REST resources; a personal access token normally bypasses it.
HTTP 404 on serverInfo Wrong base URL: include the context path, e.g. https://example.com/jira, not just the host.
HTTP 3xx ... redirected The base URL redirects (http to https, or a different host or path). Use the final URL. The script never follows redirects, to keep credentials on the host you named.
response that is not JSON A proxy, SSO page or load balancer answered instead of Jira. Run from a machine inside the network, or bypass SSO for REST with a personal access token.
TLS certificate errors bash: point curl at your CA bundle with CURL_CA_BUNDLE=/path/to/ca.pem. PowerShell: import the CA into the Windows certificate store. The scripts never disable certificate checks.
Many HTTP 429 retries Jira rate limiting for REST is on. Increase --delay-ms (e.g. 500) or ask the Jira admin to exempt the account during the snapshot.
Filter probing takes long Probing makes one request per ID. Use the SQL export filters.sql, which also includes private filters, or lower --filter-scan-gap.
exactly 100 users returned warning Jira 11.x behaviour of user/search when user/list is unavailable, or a proxy limiting results. Add users.jsonl from users.sql.
invalid JSON line in export/...jsonl The database client wrapped or truncated long lines. psql: use -A -t. mysql: use --batch --raw. sqlcmd: use -y 0 and not -W. Save the file as UTF-8.
sqlcmd: 'JIRA_SCHEMA' scripting variable not defined Add -v JIRA_SCHEMA=jiraschema (or dbo).
PowerShell: running scripts is disabled on this system powershell -ExecutionPolicy Bypass -File .\snapshot.ps1 ... or Unblock-File .\snapshot.ps1.
bash on Windows prints odd characters or \r errors Use Git Bash or WSL with jq installed; the script strips CRLF written by jq.exe.

Development and tests

The scripts have no dependencies beyond those listed above. The tests use Node.js 22:

npm ci
npm test                      # every runtime found locally (bash, pwsh, Windows PowerShell)
node tests/e2e.mjs --runtime bash
node tests/e2e.mjs --update   # regenerate tests/expected/ after an intended output change

License

MIT, copyright Nox Development.