Skip to content

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.release is 25)
  • 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-ctl does not need an extra netcat package: it talks to the management port through bash's built-in /dev/tcp and only falls back to nc (any variant, see management.md, section 4).

Building from source

mvn clean install

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

java -jar dicom-router-core/target/dicom-router-core-*.jar

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.sh lives in the source tree under bin/ 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_studies stores StudyInstanceUIDs as soon as a CMoveTrackingStage runs with store: databaseindependently of audit.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 to config/ (config/audit-retention.sql) and edited there: the periods have to be set consciously, and config/ survives updates while share/ 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 wrapper audit-retention-run.sh spelled out alongside them: one run deletes at most batch_size rows 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 reports AUDIT_RETENTION_DELETED_TOTAL 0. Background in docs/database.md in the repository (not shipped in the distribution).

First installation

  1. Extract: tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect
  2. Bootstrap config/ from share/*.example and 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:

docker run -v ./logback.xml:/app/config/logback.xml:ro ... dicom-router

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 by bin/start.sh — a manual start behaves like the service. One difference: with start.sh, variables already set in the shell win over the file, so ad-hoc overrides like LOG_DIR=/tmp/x bin/start.sh work; values are parsed the way systemd does it (no shell expansion), never sourced.

With DB_ENABLED=false the 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.cfg for the preflight (haproxy -c),
  • a sudo rule for the reload, e.g.:
    imconnect ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy
    

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.