Skip to content

Set up an IG project

You have a DHIS2 instance. It holds data sets, programs, option sets, category combinations, and an organisation-unit hierarchy, and someone outside DHIS2 now needs to read forms out of it and send data back. This page makes the directory that does that: one command writes a project that reads your instance's metadata and turns it into a published package other systems can consume. The package is a FHIR Implementation Guide; nothing on this page asks you to know what that means.

Who this is for: the DHIS2 implementer standing that project up for the first time, and the operator bringing an existing one up to the current toolchain.

Before you start: uv installed, a d2w you can run (uv tool install dhis2w-cli gives you one anywhere), and a DHIS2 instance you can reach. Docker is not needed until you compile - see Build and publish the guide.

You will be able to:

  • scaffold a complete, reproducible IG project with one command
  • install the project's pinned toolchain and know why the pin exists
  • point the project at the right DHIS2 instance, without credentials on disk
  • bring an older project up to the current d2w without losing your edits

Scaffold the project

d2w fhir init writes the whole project: config, SUSHI skeleton, a pyproject.toml that pins the toolchain, a Makefile, and a Dockerfile.

$ d2w fhir init demo-ig --id dhis2.fhir.demo \
    --canonical http://example.org/fhir/demo \
    --publisher "Demo Organisation" --profile local_basic
                                   fhir init
┌──────────┬──────────────────────────┐
│directory │ /home/you/demo-ig        │
│created   │ 13                       │
│skipped   │ 0                        │
└──────────┴──────────────────────────┘
  created fhir.toml
  created fhir.example.toml
  created ig/sushi-config.yaml
  created ig/ig.ini
  created ig/fsh.ini
  created ig/input/fsh/aliases.fsh
  created ig/input/pagecontent/index.md
  created ig/input/ignoreWarnings.txt
  created pyproject.toml
  created .python-version
  created Makefile
  created Dockerfile
  created .gitignore
next: run `d2w fhir generate` (profile `local_basic`)

The flags that matter, all optional:

Flag What it seeds
--id IG package id (default dhis2.fhir.example).
--canonical Canonical base URL, no trailing slash.
--name, --title SUSHI name and IG title, derived from --id when omitted. A name is FHIR computer-friendly - an upper-case letter, then up to 254 letters, digits or underscores - and anything else is refused, because SUSHI rewrites it into that shape without saying so. A title or name carrying < or > is refused too: the IG publisher strict-parses the pages it writes them into.
--publisher Publisher name.
--publisher-url Publisher home page. Omit it unless you have a real site: the IG publisher links it from every generated page, and pointing it at the canonical yields one broken link per page.
--status draft (default) or active; drives the sushi-config status and the status and experimental flag on every generated definitional resource.
--profile Seeds the profile key of fhir.toml, so d2w fhir generate reads that instance without a flag.
--sushi-timeout Seconds the IG publisher gives its internal SUSHI run (default 1800), written to ig/fsh.ini.
--org-unit-root Organisation unit UID seeding [generate.organisation_units] root: the top of the subtree the project publishes, and every organisation unit its forms may be assigned to. Left out, the whole instance is read - including any subtree kept for deactivated organisation units. Under --with-registry it lands in both projects. Checked for UID shape.
--org-unit-max-level Deepest organisation-unit level to generate, seeding [generate.organisation_units] max_level. Rejected below 1.
--data-set, --event-program, --tracker-program UIDs to seed the [generate.*] include_ids selection tables with (each repeatable). Naming one family narrows that family alone - an absent table means every member of its kind - so a guide naming data sets still publishes every event program and every tracker program until you name those too. Each UID is checked for shape (eleven characters, a letter then ten letters or digits) and refused when it is not one.
--with-registry Scaffold the guide and the organisation-unit registry package it depends on, as two wired projects under this directory - registry/ and guide/ - plus a Makefile driving both and a README describing the pair. Both identities derive from --id and --canonical. Rejects --publishes, --template and every --registry-*. Build a registry and a guide walks through the pair step by step.
--publishes Scaffold a package rather than a guide, holding what this names and nothing else - organisation-units is the registry package, for guides to depend on. See Publish the registry as a package. Takes no selection flag and no --registry-* flag. Omit it for a guide.
--registry-id, --registry-canonical, --registry-version, --registry-path The registry package this guide's units are published by, seeding [generate.organisation_units.registry], the dependencies: entry of sushi-config.yaml and the Makefile's REGISTRY_* knobs. The id and the canonical go together; the version defaults to 0.1.0; the path is an optional local checkout, read from the new project's own root and refused when no directory stands there.
--template Pre-populate the project from a guide already generated against a real DHIS2 instance - see Start from a template.
--list-templates Name every template this install can scaffold from, one line each, and exit.
--force Overwrite scaffold files that already exist, fhir.toml and every hand-written line in them included. Each is reported overwritten and named, and the run closes by saying how many files it replaced.
--refresh Bring an existing project's scaffold up to date - see below. Five toolchain files are rewritten whole; every other file's edits survive. Rejects --force.

Every seeding flag is offline: a value is checked for shape and then written to fhir.toml as given, and no instance is asked whether it holds the UID or answers to the profile name. init writes a minimal fhir.toml holding the IG identity and one standing choice - hostile_names = "substitute", since almost every instance names an age band with a < the IG publisher cannot survive (what it does) - plus fhir.example.toml documenting every option with its default; copy what you need across, and anything you omit keeps its default.

Choosing a max-level

--org-unit-max-level is the one flag whose value costs something on the other side of the guide. It caps the depth of the organisation-unit registry, which is most of what a national build renders: dropping from every level to level 2 can take a registry of thousands of units down to a dozen, and a multi-hour publisher run down to minutes.

What it also caps is where a form may be reported from. DHIS2 assigns a data set or a program to organisation units, usually facilities at the deepest level, and a generated guide publishes that assignment as a List of the Locations it admits. Intersect an assignment with a registry that stops at level 2 and the List is frequently empty: the form publishes, nobody can submit a response to it, $generate answers 422, and DHIS2 would refuse the capture with E1029. On the DHIS2 demo instance at --org-unit-max-level 2, 29 of 41 published Questionnaires land that way.

So the level to pick is the shallowest one that still reaches the units your selected forms are assigned to. Two ways to read it off a project you already have:

  • d2w fhir generate closes with a warning naming how many published forms carry an empty organisation-unit assignment, the data sets and programs the assignment hangs on, and the max_level in force.
  • d2w fhir check-artifacts reports the same forms as warning-level findings, offline, one per form.

If the number is not zero, either widen the organisation-unit selection - raise max_level, or set its root higher up the hierarchy - until the registry reaches the assigned organisation units, or narrow the form selection to the forms those organisation units report. Both commands prescribe that same pair in those words. A guide of forms nobody may report is a valid build and a useless one.

Start from a template

Everything above scaffolds an empty project: the skeleton is there, and the guide inside it arrives when d2w fhir generate reads your instance. A template skips that wait. --template lays down a guide someone already generated against a real DHIS2 instance, so the project compiles and serves without reaching an instance at all.

$ d2w fhir init --list-templates
                              fhir init --template (8)
┌────────────────────────┬──────────┬────────────────────────────────────────────┐
│template                │ ships in │ publishes                                  │
├────────────────────────┼──────────┼────────────────────────────────────────────┤
│aggregate-minimal       │ bundled  │ The smallest complete guide: the forms,    │
│                        │          │ one district, no category axes.            │
│event-program           │ bundled  │ A program without registration: one        │
│                        │          │ Questionnaire, one occurrence date, no     │
│                        │          │ enrollment.                                │
│patient-summary         │ bundled  │ An International Patient Summary at        │
│                        │          │ $summary: who a person is, and which       │
│                        │          │ recorded values are doses.                 │
│aggregate-disaggregated │ checkout │ The same selection with every category     │
│                        │          │ axis published - the category pairs and    │
│                        │          │ their ConceptMaps.                         │
│facility-mixed          │ checkout │ One of every capture kind at once - the    │
│                        │          │ flagship.                                  │
│registry-district       │ checkout │ One district's organisation units as       │
│                        │          │ Organization and Location pairs,           │
│                        │          │ boundaries and all.                        │
│terminology-strict      │ checkout │ Concept codes taken from DHIS2 codes       │
│                        │          │ rather than DHIS2 UIDs, and what that      │
│                        │          │ trades.                                    │
│tracker-registration    │ checkout │ Registering a person, enrolling them, and  │
│                        │          │ answering two stage forms.                 │
└────────────────────────┴──────────┴────────────────────────────────────────────┘
note: a bundled template rides the installed package; a checkout one is read from
examples/igs/ of the dhis2w repository and exists only in a clone of it

The listing comes off the template manifest and the catalog's own declarations, so it names what this install actually holds rather than what some page once said it held. A bundled template's line is written for it in the manifest; a checkout one's is the summary of its own template.toml.

An example is a template only when it says so. Each guide under examples/igs/ carries a template.toml beside its fhir.toml, holding either scaffolds = true with the summary the listing prints, or scaffolds = false with the refusal --template prints instead. The catalog holds exhibits as well as guides - refused-names exists to show a selection d2w fhir generate refuses, so it has no generated tree to lay down and nothing make sushi can compile - and the declaration is what keeps one of those out of the listing:

$ d2w fhir init demo --template refused-names
error: `refused-names` is an example, not a template. It demonstrates the names
`d2w fhir generate` refuses: a selection whose DHIS2 names carry a raw '<', which
aborts the IG publisher's last pass. The run is refused on the first such name,
which leaves the foundation target on disk and nothing after it, so this example
carries no generated tree to lay down and `make sushi` has nothing it can
compile. Read it at examples/igs/refused-names/README.md.

Bundled or checkout. A bundled template rides the installed package and works anywhere d2w does. A checkout one is read from examples/igs/ of the dhis2w repository, which no wheel carries, so it scaffolds only from a clone. Three of the eight ride the wheel; asking an installed package for one of the other five is refused by saying where it lives:

$ d2w fhir init demo --template facility-mixed
error: template `facility-mixed` belongs to the example catalog, which ships in the
dhis2w repository rather than in an installed package. This install carries
aggregate-minimal, event-program, patient-summary. Run `d2w fhir init` from a clone
of the repository to scaffold from `facility-mixed`.

What a template supplies, and what your flags supply. The template is the default and the flag wins:

Value Without a flag With a flag
--id, --canonical The template's own. Yours.
--name, --title The template's own, but only while you keep its --id; name a different --id and they derive from yours as usual. Yours.
--publisher, --status, --publisher-url The scaffold's defaults, never the template's - a project you scaffold is published by you. Yours.
--profile Absent, exactly as an ordinary init. A template serves without an instance, so nothing needs one until you generate. Yours.
The [generate] and [ips] selection The template's, appended to the scaffolded fhir.toml. Refused - see below.

--canonical reaches the whole tree. A pre-built resource states its canonical in full - Questionnaire.url, CodeSystem.url, every valueSet reference - 545 of them in the smallest template. Scaffolding rewrites every one from the template's address to yours, so a project with a canonical of its own publishes nothing under the template's name.

A template refuses a selection of its own. --data-set, --event-program, --tracker-program, --org-unit-root, and --org-unit-max-level are rejected beside --template: the template ships the tree its own selection produced, and writing a different selection into fhir.toml would state one thing in the configuration and another in the files beside it. Scaffold first, then edit fhir.toml and run d2w fhir generate against an instance that holds what you named.

Your scaffold files stay yours. Three of the files init writes live under ig/input/ - fsh/aliases.fsh, pagecontent/index.md, and ignoreWarnings.txt. A template payload never lands on one of them, so --refresh still maintains exactly the files it wrote.

To take a template somewhere real, point the project at your own instance and regenerate: set profile, edit the selection to UIDs that instance holds, and run d2w fhir generate. That rewrites ig/input/ from your metadata, and the template's content is gone - which is the intent. Until then it is a working guide you can read, compile, and serve.

Install the pinned toolchain

The scaffolded project is a uv project. Its pyproject.toml declares dhis2w-cli, dhis2w-fhir, and dhis2w-fhir-serve - the CLI, the generator, and the server d2w fhir serve runs on:

$ cd demo-ig
$ uv sync
Resolved 97 packages in 4ms
Checked 91 packages in 11ms

(The first run also downloads and installs the packages; later runs just check.) Three facts make the project reproducible:

  • uv.lock is committed. The FSH a project publishes is a function of the d2w build that wrote it, so the lock is what makes a regenerate reproducible on any machine. The scaffolded .gitignore covers .venv/ and deliberately does not cover uv.lock.
  • .python-version pins the interpreter (3.13, matching requires-python). An older project gains it via d2w fhir init --refresh.
  • The three packages resolve from PyPI as one release. Each package's own dependency floors hold the CLI, the generator, and the server to the same version, and the lock pins the exact one - a CLI paired with a plugin from a different build is not a combination anyone tests. The published wheels are also what ships the capture UI. To track the repository's main branch instead of releases, add a [tool.uv.sources] git entry per package; the scaffolded pyproject.toml shows the shape.

Every scaffolded make target drives d2w through uv run, so make validate and make generate are uv run d2w fhir validate / uv run d2w fhir generate against the pinned build - spell either form, they do the same thing:

$ make help
help         Show available targets
setup        Build the SUSHI + IG publisher docker image
upgrade      Rebuild the image from scratch, pulling the latest SUSHI + IG publisher
generate     Regenerate FSH from DHIS2 metadata
update       Update the project to the current d2w: move the toolchain pin, sync, refresh the scaffold-managed files
validate     Check the DHIS2 instance's codes/names for FHIR-safety (writes md/csv/pdf; exit 1 on errors)
check        Scan the artifacts on disk for what aborts the IG publisher (offline, seconds, exit 1 on findings)
cache-init   Ensure the shared package-cache volume is writable by the publisher user
sushi        Compile FSH to FHIR resources
build        Run the full IG publisher
serve        Serve the compiled IG as a FHIR endpoint, with the capture UI at / (run generate + sushi first)
serve-live   Serve straight from the DHIS2 instance, with the capture UI at / - no compile needed
forward      Dry-run the capture spool against DHIS2 - validate only, nothing written, nothing moved
forward-import Import the capture spool into DHIS2 and file every receipt by what it became
clean        Remove build output (keeps ig/input-cache, the terminology cache the next build reuses)
clean-all    Also remove the terminology cache and the FHIR package cache volume
refresh      Wipe build output, pull latest tooling, regenerate, revalidate, rebuild (keeps the caches)

Move the pin when you want the newer toolchain, not by accident:

uv lock --upgrade --refresh
uv sync

--refresh makes uv re-read the package index instead of its cache, so a release published minutes ago is seen and the CLI, the generator and the server land on the same one.

Then regenerate and rebuild against the new pin. make refresh is the one target worth using over the raw commands, because it is a chain no single command replaces: it drops both caches, rebuilds the docker image from scratch, then runs d2w fhir generate, d2w fhir validate, and the publisher in order - tolerating validate's exit 1 so a full rebuild still produces fresh reports, and skipping SUSHI because the publisher runs its own.

make refresh

Point it at a DHIS2 instance

Generation reads its config from the nearest fhir.toml, discovered by walking up from the working directory - the same idiom as .dhis2/profiles.toml. The profile it connects with resolves in this order, first match wins:

Order Source
1 the global -p / --profile option on the d2w command
2 the DHIS2_PROFILE environment variable
3 the profile key in fhir.toml
4 the default profile from your profiles.toml

Write step 3 while scaffolding with d2w fhir init --profile <name>, or set the key by hand later - both land in the same place. If you have no profile yet, create one:

$ DHIS2_PASSWORD=district uv run d2w profile add local_basic --auth basic --username admin \
    --url http://localhost:8080 --local --default
profile 'local_basic' saved to /home/you/demo-ig/.dhis2/profiles.toml

--local writes it into the project's own .dhis2/profiles.toml, so the project carries its instance with it. A name that also exists in your global store is not an error - the run says which one wins:

warning: a profile named 'local_basic' also exists in the global scope; the project-scoped
one will override it when you're in this directory.

Secrets are never command-line flags - the password comes from the DHIS2_PASSWORD environment variable (or an interactive prompt when it is unset).

Credentials never live in fhir.toml. It is committed project config: it names a profile, and the profile store holds the secret.

d2w fhir validate needs no fhir.toml at all - it targets an instance, not a project, so it runs anywhere. See Validate the instance for what changes inside a project.

Refresh an older project's scaffold

The scaffold grows: a path-resource glob lands in ig/sushi-config.yaml, an entry lands in .gitignore, a menu entry lands beside the others - and a project scaffolded before that carries none of them. --refresh re-renders the scaffold for an existing project and writes what it safely can:

$ d2w fhir init . --refresh
                              fhir init --refresh
┌──────────────────────────┬────────────────────┐
│directory                 │ /home/you/demo-ig  │
│created                   │ 0                  │
│rewritten (scaffold-owned)│ 0                  │
│refreshed                 │ 0                  │
│refreshed, with your      │ 0                  │
│additions                 │                    │
│unchanged                 │ 12                 │
│with your additions       │ 0                  │
│diverged (kept)           │ 0                  │
└──────────────────────────┴────────────────────┘
  unchanged fhir.example.toml
  unchanged ig/sushi-config.yaml
  unchanged ig/ig.ini
  unchanged ig/fsh.ini
  unchanged ig/input/fsh/aliases.fsh
  unchanged ig/input/pagecontent/index.md
  unchanged ig/input/ignoreWarnings.txt
  unchanged pyproject.toml
  unchanged .python-version
  unchanged Makefile
  unchanged Dockerfile
  unchanged .gitignore
note: fhir.toml is yours - a refresh never writes it

Two rules decide what a refresh writes.

The Makefile, the Dockerfile, .python-version, ig/ig.ini and ig/fsh.ini are the scaffold's own files, and a refresh rewrites each of them from the current render whenever it differs. There is nothing in them to protect: every knob the Makefile has is a ?= default - D2W, TX_SERVER, JAVA_HEAP - that you set on the command line (make build JAVA_HEAP=8g) or in the environment, so your override lives outside the file and outlives the refresh; the Dockerfile and .python-version pin the image and the interpreter; and ig/ig.ini and ig/fsh.ini carry values the project states elsewhere - the guide's id from fhir.toml, and the [FSH] timeout the refresh reads off the file and writes back unchanged. So a scaffold revision that replaces a line in one of them lands whole, reported rewritten (scaffold-owned), and the file's own header says the same thing: an edit written into it does not survive a refresh. What you change about a Makefile knob belongs on the command line or in the environment, where the value lives outside the file.

A directory scaffolded with --with-registry refreshes as both projects plus the two files the directory itself holds, and those two take the same rule from either side of it: the Makefile that drives the two projects is the scaffold's own and is rewritten whole, reported the same way, and the README.md beside it is prose that goes through the line rule below, so a deployment note written into it is kept. Two of that README's lines are the guide's identity rather than yours - the title on its cover, and the registry canonical it names - and the identity rule below writes both.

Every other file is rewritten only when the current scaffold render reproduces every line already on disk, in order. So a refresh can only add what the scaffold gained, and no line you wrote is ever dropped. Every file gets one of seven outcomes, all of them printed:

Outcome Meaning
created A scaffold file the project did not have. Written.
rewritten (scaffold-owned) One of the five toolchain files the scaffold owns outright. Replaced whole by the current render, so an edit to it is gone.
refreshed A file whose render carries every line on disk plus more, or one whose identity lines fhir.toml declares have changed. Rewritten line-preservingly: every line you wrote is still there - except in fhir.example.toml, whose comment prose the scaffold owns outright (see below).
refreshed, with your additions Both at once: an identity line landed on a file that also carries lines the render does not produce. Written, and the lines you added are still under it - so a title change and an appended section read as two facts rather than one.
unchanged Already byte-identical to the current scaffold.
with your additions Carries every line the current scaffold renders, plus lines of your own. Nothing to add, so nothing is written.
diverged (kept) Holds lines the current scaffold does not write - your edits, or scaffold lines that have since changed; a line-preserving refresh cannot tell which. Your version stays, reported as kept <path> (holds lines the current scaffold does not write). To take the scaffold's version, delete the file and refresh again.

fhir.example.toml is graded on the keys it sets. It is the catalog of every option a project may set, and the sentences explaining those options are re-worded release by release - so a project scaffolded by an older release carries pages of prose the current render does not produce and nothing of anybody's own. The ladder reads it through its TOML alone: the table headers, the keys and the values you would copy into fhir.toml. A file whose settings the current render reproduces is a render of this scaffold under some release's wording, so the render lands and the outcome is refreshed; one carrying a key or a value the render does not produce is diverged (kept) like any other file. The prose is the scaffold's, and a comment written into that file does not survive a refresh.

fhir.toml is never written - it is your configuration, and a refresh skips it outright. The IG identity comes off the project itself: [ig] and the selection tables from fhir.toml, the SUSHI timeout from ig/fsh.ini, and the publisher URL plus copyright year from ig/sushi-config.yaml.

The identity lines are the one exception to the rule above. fhir.toml declares them, so the refresh writes them wherever they appear: in ig/sushi-config.yaml (the guide's id, canonical, name, title, the one-line description built from the title, status, the publisher's name, and the six special-url lines that follow [generate] identifier_system_base), in the [ig] table of fhir.example.toml, on the first line of the front page at ig/input/pagecontent/index.md, in the ig = line of ig/ig.ini, as the project name in pyproject.toml, and - in a directory scaffolded with --with-registry - on the first line of the pair's README.md and the line naming the registry canonical. Change a title in fhir.toml and one refresh puts it on the guide's cover and its front page, reporting each file as refreshed - ig/ig.ini as rewritten (scaffold-owned), since the toolchain owns that one. Every other line of those files stays exactly as you wrote it - releaseLabel, version, the publisher's home page, copyrightYear, the parameters, the menu, the path-resource globs, your own prose and headings, an option you uncommented in the example. Until the refresh runs, d2w fhir generate says so with a scaffold-drift note naming the keys fhir.toml and ig/sushi-config.yaml state differently.

A scaffold line you deliberately deleted comes back

Deleting a line leaves every remaining line still present in the render, in order - which is exactly the shape a refresh rewrites. To keep a scaffold line out, change it into something the scaffold would not produce - comment it out, or edit it - rather than removing it. To go the other way and take the scaffold's version of a kept file, delete the file and refresh again; it comes back as created. Neither applies to the five files the scaffold owns outright: an edit to any of them is replaced by the render, so keep what you change to them in the command line or the environment.

--refresh and --force are mutually exclusive, and the run stops if you pass both: --force rewrites every scaffold file including the ones you edited, --refresh rewrites only what it can rewrite without losing an edit. They are opposite answers to the same question.

--force over a project that already stands there is a rewrite, and the report says so rather than calling it a scaffold: each file that already existed is reported overwritten <path> and counted apart from created, and the run closes with what it replaced.

$ d2w fhir init . --force
  overwritten fhir.toml
  overwritten fhir.example.toml
  ...
note: --force replaced 13 file(s) that already stood here, listed above - the
contents they held, fhir.toml and every hand-written line included, are gone

There is no confirmation prompt - d2w is not interactive - so the honesty is in the report. fhir.toml goes with the rest: the file --refresh calls yours is the first one --force replaces.

Three verbs share a word, so keep them apart: init --refresh touches the scaffold and never the generated output; the scaffolded make refresh rebuilds the IG from the instance; and the one-command way to bring an older project up to the current d2w is make update - it moves the toolchain pin (uv lock --upgrade --refresh), syncs, and runs init --refresh, pin first, so the refresh runs on the d2w it just installed.

Why this exists is concrete: a project scaffolded before path-resource covered a predefined-resource sub-folder keeps a sushi-config.yaml without that glob. SUSHI loads the pre-built JSON regardless, so make sushi stays green - but the IG Publisher does not recurse, and silently drops those resources from the published guide. A refresh adds the glob.

Next: Validate the instance