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 generatecloses with a warning naming how many published forms carry an empty organisation-unit assignment, the data sets and programs the assignment hangs on, and themax_levelin force.d2w fhir check-artifactsreports 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:
(The first run also downloads and installs the packages; later runs just check.) Three facts make the project reproducible:
uv.lockis 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.gitignorecovers.venv/and deliberately does not coveruv.lock..python-versionpins the interpreter (3.13, matchingrequires-python). An older project gains it viad2w 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
mainbranch instead of releases, add a[tool.uv.sources]git entry per package; the scaffoldedpyproject.tomlshows 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:
--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.
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