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.
| File | Size | What it is |
|---|---|---|
migration-doctor-snapshot.zip | 58.8 KB | Everything below in one archive: both scripts, SQL queries, schema, README, license. |
snapshot.ps1 | 51.6 KB | PowerShell edition (Windows PowerShell 5.1, PowerShell 7+). |
snapshot.sh | 41.9 KB | bash edition (Linux, macOS, Git Bash, WSL; needs curl and jq). |
sql/ | Read-only SQL queries for PostgreSQL, MySQL and SQL Server. | |
schema/snapshot.schema.json | 7.7 KB | JSON Schema of the snapshot file. |
README.md | 24.5 KB | This page as Markdown. |
CHANGELOG.md | 1.3 KB | Changes by version. |
LICENSE | 1.0 KB | MIT license. |
SHA256SUMS | SHA-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
- Privacy
- Requirements
- Usage
- Read-only SQL exports (filters, dashboards, workflow rules)
- Output format
- REST endpoints used
- Jira versions: 8.20, 9.x, 10.x, 11.x
- Troubleshooting
- Development and tests
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.
- The file stays on the machine where you run the script. The script sends it nowhere.
- Keep it on that machine until you upload it into the Migration Doctor app on your own Jira Cloud site. Migration Doctor runs on Atlassian (Forge, no egress): the snapshot is stored inside Atlassian's platform for your site and is not sent to Nox Development or any third party.
- In the app you can delete an uploaded snapshot at any time. Delete the local file when the migration review is finished.
- On Linux and macOS the bash edition creates the file readable by your user only (mode 600).
- Credentials are read from environment variables, are sent only in the
Authorizationheader to your Jira, and are never written to the output, the console or a log. The bash edition passes them to curl on standard input, so they do not appear in the process list.
Requirements
- A Jira Data Center (or Server) instance, version 8.20 to 11.x. 9.x is the primary target.
- A Jira System Administrator account. Workflows, permission schemes and the user list need admin rights.
- A personal access token (recommended, Jira 8.14+): profile picture, Profile, Personal Access Tokens, Create token. Give it a short expiry and revoke it after the snapshot. Basic authentication with username and password also works.
- Network access from where you run the script to the Jira base URL.
- PowerShell edition: Windows PowerShell 5.1 or PowerShell 7+. Nothing to install.
- bash edition: bash 3.2+, curl, jq 1.6+ (
apt install jq,dnf install jq,brew install jq,winget install jqlang.jq).
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:
- Lines that are not JSON objects (headers, row counts, blank lines) are ignored, so minor client output differences do not matter. A UTF-8 BOM is accepted.
- Owners and shared-with users are resolved from user keys (
JIRAUSER10100) to usernames throughapp_userand the first active user directory, like Jira does. A deleted user's filter keeps the lower-case username fromapp_user, or the raw key if even that is gone. - The system workflow
jirais built into Jira and is not stored in the database, so it has no transitions in the snapshot (transitionsAvailable: false). It cannot be edited in DC either. plugins.sqllists installed apps (pluginversion). It lets the script attribute a workflow rule to the app that provides it (pluginKey, e.g. ScriptRunner or JSU) instead of guessing.- The workflow descriptor XML is parsed by the script (PowerShell: .NET XML; bash: a small XML
tokenizer in jq). Each transition lists its source statuses (
from), target status (to), and every condition (including nested condition groups), validator and post function with its implementation class, module key and providing app. Hidden built-in post functions (create issue, fire event, reindex) are included as they are part of the transition in DC.
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
tests/mock-jira.mjsis a mock Jira DC server that serves realistic recorded responses fromtests/fixtures/by path and query (including paged responses, a 429 withRetry-After, 400/403 for invisible filters and boards, and a context path). It answers anything but GET with 405 and logs every request, so the tests prove the scripts are read-only.tests/e2e.mjsruns each edition against it (DC 9.12 REST only, 9.12 with SQL exports, 11.0 with--projectsand basic auth, and error cases), validates the output against the schema with Ajv, compares it with the same golden files intests/expected/for every runtime (so the editions must produce equivalent output), and checks that no secret appears in the output or console.tests/sql/holds a model of the Jira tables the queries read. In CI it seeds PostgreSQL, MySQL and SQL Server, runs every query with the real client, compares the result withtests/fixtures/sql-export/, and feeds each database's output through the script.- CI (
.github/workflows/ci.yml): shellcheck and PSScriptAnalyzer; bash on Ubuntu with jq 1.6 and the current jq; bash 3.2 on macOS; PowerShell 7, Windows PowerShell 5.1 and Git Bash on Windows with a cross-runtime equivalence check; the SQL job.
License
MIT, copyright Nox Development.