Deployment & Operations¶
How to build, package and operate the DICOM Router. This is the reference for
the current build; the authoritative step list for installation/updates ships
with the distribution itself in deploy/systemd/dicom-router.service (its
header documents the exact commands).
Related documentation: User Guide · Configuration · Management & Drain
Prerequisites¶
- Java 25 or later (
maven.compiler.releaseis25) - Maven 3.9+
- PostgreSQL — only if database-backed connection tracking is used
(
DB_ENABLED=true). DICOM routing works without it. - Bash on the operating hosts for the scripts under
bin/.dicom-router-ctldoes not need an extranetcatpackage: it talks to the management port through bash's built-in/dev/tcpand only falls back tonc(any variant, see management.md, section 4).
Building from source¶
The full build takes ~2 minutes. The runnable application is
dicom-router-core/target/dicom-router-core-<version>.jar, with its
dependencies under dicom-router-core/target/lib/ and the example plugins JAR
under dicom-router-plugins/target/.
Running directly¶
The application looks for its configuration at ./config/dicom-router.yaml
(relative to the working directory). If the file does not exist, a default
template is copied out of the JAR on first start. See
configuration.md for the YAML structure and
management.md (Logging section) for how logging is resolved.
Distribution tar.gz (production)¶
The release archive (built by CI, .gitea/workflows/build.yml) separates
application code (replaced on every update) from operator-managed
configuration (preserved across updates). The tar.gz contains the app side
only; config/ is created by the operator on first installation and is never
touched by tar.
/opt/imconnect/ # extracted from tar.gz (app side)
├── dicom-router-core-<version>.jar # main application
├── lib/ # dependencies
├── plugins/ # example plugins JAR
├── bin/
│ ├── start.sh # loads config/dicom-router.env, sets -Dlogback.configurationFile, then java -jar
│ ├── dicom-router-ctl # management CLI
│ ├── dicom-router-sync # distribute config to all cluster hosts + reload
│ └── dicom-router-haproxy-sync # distribute HAProxy return-path config + reload
├── share/ # templates — copied to config/ on first installation
│ ├── dicom-router.yaml.example
│ ├── logback.xml.example
│ ├── dicom-router.env.example
│ ├── dicom-router.service.example
│ ├── cluster-hosts.example
│ ├── haproxy-hosts.example # HAProxy hosts for dicom-router-haproxy-sync
│ ├── audit-retention.sql.example # retention example for the audit tables (psql)
│ ├── audit-timestamp-utc-fix.sql.example # one-off timestamp correction for data written before 1.17.2
│ └── haproxy-example.cfg
├── docs/ # operator documentation offline (user guide, plugin reference, configuration, management, deployment)
├── README.md
├── CHANGELOG.md
├── LICENSE # proprietary terms of use
└── config/ # operator-managed, survives updates
├── dicom-router.yaml
├── logback.xml
└── dicom-router.env # chmod 600 — contains DB credentials
bin/start.shlives in the source tree underbin/like the other scripts; the CI build copies it into the distribution unchanged.
share/audit-retention.sql.example(source:deploy/sql/audit-retention.sql.example) is a commented psql script that purges the audit and the Q/R tracking tables of the database after a configurable period. It therefore matters beyond audit installations:cmove_tracking_studiesstores StudyInstanceUIDs as soon as aCMoveTrackingStageruns withstore: database— independently ofaudit.enabled. Anyone running the Q/R bridge on the database store needs the script even with the audit trail switched off; otherwise expired tracking rows and their UIDs are left lying around (the application only cleans up incidentally, when it happens to reuse the same number). The router never reads it and does not need it to start — it deletes nothing on its own, retention is deliberately the operator's job and at the same time a data-protection measure. Like the other templates it is nevertheless copied toconfig/(config/audit-retention.sql) and edited there: the periods have to be set consciously, andconfig/survives updates whileshare/is replaced by every update. The cron and systemd-timer examples inside the script refer to that path. They do not call the script directly but the loop wrapperaudit-retention-run.shspelled out alongside them: one run deletes at mostbatch_sizerows per statement, so a single call per day only keeps up below that volume. The wrapper calls the script in a loop until its closing line reportsAUDIT_RETENTION_DELETED_TOTAL 0. Background indocs/database.mdin the repository (not shipped in the distribution).
First installation¶
- Extract:
tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect - Bootstrap
config/fromshare/*.exampleand adjust (dicom-router.yaml,logback.xml,dicom-router.env).
The complete step list (dedicated user, ownership, directories) is in the
header of deploy/systemd/dicom-router.service — that file is the
authoritative source.
Updates¶
systemctl stop dicom-router
# Remove the previous release's app files first: the module JARs are version-named,
# extracting over the old tree accumulates them and start.sh refuses to start
# (before v1.6.1 it silently started the OLD core JAR instead).
# config/ and any third-party plugin JARs remain untouched.
rm -f /opt/imconnect/dicom-router-core-*.jar
rm -f /opt/imconnect/plugins/dicom-router-plugins-*.jar
rm -rf /opt/imconnect/lib /opt/imconnect/share
tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect
systemctl start dicom-router
The config/ directory is preserved across updates.
systemctl stop stops without data loss: the unit drains first (no new
connections) and waits until all in-flight transfers have completed — so the
stop can take a while, e.g. while a large C-STORE is running. New connections
are rejected in the window between stop and start; if that must be
avoided, run multiple instances behind HAProxy and update in a rolling
fashion (drain one instance, update it, resume it — see
management.md).
systemd¶
The source templates are in deploy/systemd/ (dicom-router.service,
dicom-router.env.example); CI copies them to share/ with the .example
suffix. The unit loads all runtime settings (LOG_DIR, DB credentials,
JAVA_OPTS, …) from the env file (EnvironmentFile=). Use it as the basis
for /etc/systemd/system/.
The unit hardens with ProtectSystem=strict and only allows writes to
/var/imconnect and /var/log/imconnect (plus /tmp//var/tmp via
PrivateTmp). /opt/imconnect — the app directory itself — is deliberately
not writable: the process only reads from it (JAR, lib/, config/) and
never writes, so a read-only state prevents a compromised process from
tampering with its own binaries or its configuration. The audit spool
(audit.spool-path, default /var/imconnect/audit-spool) must stay within
one of the writable paths — outside of them the audit trail cannot open its
spool and disables itself (loudly, routing unaffected). If multiple router
instances run on one host, give each its own spool-path (the spool
directory is per instance).
File permissions: router.storage-path holds transiently unencrypted patient
data (P-DATA spill files during processing), as do PDU dumps
(router.log-folder), tap captures/spools and the audit spool
(audit.spool-path: unencrypted JSONL with patient ID, study/SOP instance UIDs
and accession number, plus the patient name with capture-patient-name — entries
sit there until the shipper has moved them into the database); the router creates
all these files and its own directories owner-only (0600/0700). Deletion is a plain
unlink, not an overwrite of the blocks — to rule out the residual risk on the
storage medium, place the paths on an encrypted volume. The unit additionally
sets UMask=0027, so everything else the process writes (the application logs,
for example) is created at most group-readable.
The env file config/dicom-router.env belongs at chmod 600. It contains
JDBC_PASSWORD in cleartext, and bin/start.sh builds the JVM command
line from JAVA_BIN/JAVA_OPTS — write access to the file therefore means
code execution as the router user. bin/start.sh reads the file's
permission bits at startup and writes a warning to stderr if anything
beyond the owner is set; the start continues regardless, and where no
stat is available the check is skipped without a word. It only says
something about the permission bits of this one file. Ownership and the
permissions of the config/ directory are not checked, even though both
count just as much: systemd reads EnvironmentFile= as root, including a
file that does not belong to the router user at all, and a group- or
world-writable config/ allows the file to be swapped out without its
permission bits changing. Both remain the operator's responsibility.
UMask=0027 does not apply here: the setting only covers files the router
process writes itself. The env file comes into being when it is copied from
share/dicom-router.env.example and carries whatever permissions are set
then.
Docker¶
The image (docker/Dockerfile) is not a multi-stage build — it expects
the already-built artifacts under docker/app/, analogous to the CI workflow.
Building locally:
# 1) Build everything
mvn clean install
# 2) Stage the artifacts into the Docker build context
VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)
mkdir -p docker/app/lib docker/app/bin docker/app/plugins docker/app/share docker/app/config
cp dicom-router-core/target/dicom-router-core-${VERSION}.jar docker/app/dicom-router.jar
cp dicom-router-core/target/lib/*.jar docker/app/lib/
cp bin/dicom-router-ctl docker/app/bin/
cp dicom-router-core/src/main/resources/logback.xml docker/app/config/logback.xml
cp dicom-router-plugins/target/dicom-router-plugins-${VERSION}.jar docker/app/plugins/
cp LICENSE docker/app/
# Reference templates baked into share/ (the Dockerfile COPYs app/share/ → /app/share/).
# share/ mirrors the distribution share/ 1:1 — the same 8 templates as in the tar.gz:
cp dicom-router-core/src/main/resources/logback.xml docker/app/share/logback.xml.example
cp dicom-router-plugins/src/main/resources/example-dicom-router.yaml docker/app/share/dicom-router.yaml.example
cp deploy/systemd/dicom-router.service docker/app/share/dicom-router.service.example
cp deploy/systemd/dicom-router.env.example docker/app/share/
cp deploy/cluster-hosts.example docker/app/share/
cp deploy/haproxy-hosts.example docker/app/share/
cp deploy/sql/audit-retention.sql.example docker/app/share/
cp deploy/sql/audit-timestamp-utc-fix.sql.example docker/app/share/
cp deploy/haproxy/haproxy-example.cfg docker/app/share/
# 3) Build and run (DICOM 11112, agent check 8404, management 8405)
docker build -t dicom-router docker/
docker run -p 11112:11112 -p 8404:8404 -p 8405:8405 -v ./config:/app/config dicom-router
Mount your own logback.xml to override the baked-in default:
Environment variables¶
| Variable | Description | Default |
|---|---|---|
DB_ENABLED |
Enable/disable database connectivity (true/false) |
true |
JDBC_URL |
JDBC connection URL | jdbc:postgresql://localhost:5432/dicomrouter |
JDBC_USER |
Database user | from persistence.xml |
JDBC_PASSWORD |
Database password | from persistence.xml |
HIBERNATE_SHOW_SQL |
Log SQL statements | false |
HIBERNATE_FORMAT_SQL |
Format logged SQL | false |
HIKARI_MAX_POOL_SIZE |
Maximum connection pool size (minimum 3: a schema migration holds the cluster lock on a connection of its own while the migration itself runs on a second one — details in docs/database.md in the repository) |
10 |
HIKARI_MIN_IDLE |
Minimum idle connection pool size | 2 |
LOG_DIR |
Directory for application logs (used by logback.xml, not by Java) |
/var/log/imconnect |
DICOM_ROUTER_TOKEN_FILE |
Token file for the state-changing commands of dicom-router-ctl (used by the CLI, incl. systemd ExecStop; only needed if management.token-file points to a non-default location, see management.md) |
/opt/imconnect/config/management.token (if present) |
All variables live in
config/dicom-router.env, which is loaded both by the systemd unit (EnvironmentFile=) and bybin/start.sh— a manual start behaves like the service. One difference: withstart.sh, variables already set in the shell win over the file, so ad-hoc overrides likeLOG_DIR=/tmp/x bin/start.shwork; values are parsed the way systemd does it (no shell expansion), neversourced.With
DB_ENABLED=falsethe router starts without any database connection. DICOM routing works fully, but connection tracking is not persisted — useful for standalone deployments without PostgreSQL.
Behind HAProxy¶
For rolling zero-downtime deployments the router provides an agent-check port
and a management port and supports drain/reload via dicom-router-ctl. A
complete HAProxy example ships with the distribution as share/haproxy-example.cfg
(repository source: deploy/haproxy/haproxy-example.cfg). Its optional statistics page
binds to 127.0.0.1:8080 because it exposes backend addresses, states and traffic
counters. To reach it from outside, open an SSH tunnel
(ssh -L 8080:127.0.0.1:8080 <haproxy-host>) or change the bind address deliberately
and put authentication in front of it. The
operational details (ports, states, commands, config reload rules) are
documented in management.md.
The example deliberately puts the path with and the one without PROXY protocol
on two separate router ports (11112 and 11113). The router decides per
connection from the proxyprotocol: entries — sender address plus the port the
connection was accepted on — whether a PROXY header is expected; on a shared
port the two paths are indistinguishable and one of them breaks. A router
process binds exactly one DICOM port (router.listener.port), so the second
port is a second instance.
For devices that whitelist calling IPs, the router supports HAProxy return
paths (haproxy-return + return-port on the destinations, see
configuration.md): the router automatically dials
VIP:return-port, and it generates the matching HAProxy listen blocks itself
from the YAML — --render-haproxy, documented in management.md.
bin/dicom-router-haproxy-sync distributes the generated file to the HAProxy
hosts from config/haproxy-hosts (template: share/haproxy-hosts.example) and
reloads HAProxy seamlessly — flow and modes in management.md.
Prerequisites on every HAProxy host:
- passwordless (key-based) ssh access from the editing host (as for
dicom-router-sync), - write access for the ssh user to the target path (default
/etc/haproxy/conf.d/dicom-return.cfg), - a readable
/etc/haproxy/haproxy.cfgfor the preflight (haproxy -c), - a sudo rule for the reload, e.g.:
The reload command can be overridden via DICOM_ROUTER_HAPROXY_RELOAD_CMD
(setups differ, conf.d vs. EXTRAOPTS -f); the script never manages the
main haproxy.cfg.