Configuration
Environment variables, TLS setup, connector YAML, and CEL validation rules.
All server settings are environment variables. Connector routing and CEL validation rules are defined in a YAML file. No flags. No config file for the server itself.
See Getting Started for a quick run-through, and Troubleshooting for common problems.
Server
| Environment Variable | Default | Description |
|---|---|---|
LISTEN_ADDR | :2575 | TCP address to bind. Use host:port or :port. |
IDLE_TIMEOUT | 30s | Maximum time a connection may be idle between MLLP frames before it is closed. |
WRITE_TIMEOUT | 5s | Maximum time allowed to write an ACK response back to the sender. |
FRAME_TIMEOUT | 60s | Maximum time to receive a complete MLLP frame after the Start Block byte is seen. |
MAX_FRAME_SIZE | 2097152 | Maximum MLLP frame payload size in bytes. Default is 2 MB (2097152). |
MAX_CONNECTIONS | 0 | Maximum concurrent connections. 0 means unlimited. |
KEEP_ALIVE_PERIOD | 60s | TCP keep-alive interval. Set to 0 to disable keep-alive. |
CONNECT_TIMEOUT | 10s | TLS handshake timeout. Set to 0 to disable. Applies only when TLS is enabled. |
DEFAULT_CHARACTER_SET | (none) | Fallback value for MSH-18 when the field is absent. Examples: 8859/1, UNICODE UTF-8. |
Duration values accept Go duration syntax: 30s, 5m, 1h30m.
TLS
| Environment Variable | Default | Description |
|---|---|---|
TLS_CERT_FILE | (none) | Path to the PEM-encoded server certificate. TLS is disabled when this is unset. |
TLS_KEY_FILE | (none) | Path to the PEM-encoded private key. Required when TLS_CERT_FILE is set. |
TLS_CLIENT_CA | (none) | Path to a PEM-encoded CA bundle. When set, mutual TLS (mTLS) is enforced. |
TLS_MIN_VERSION | 1.2 | Minimum TLS version. Accepted values: 1.2, 1.3. |
TLS is enabled when both TLS_CERT_FILE and TLS_KEY_FILE are set. Omitting both runs a plain TCP server.
Cipher suites (TLS 1.2). The server enforces a curated list of AEAD cipher suites for TLS 1.2 connections. TLS 1.3 cipher selection is managed by Go’s standard library and is not configurable.
Certificate hot-reload. Send SIGHUP to reload the certificate and key from disk without restarting the server. The reload is atomic — in-flight connections continue using the previous certificate.
mTLS setup. Set TLS_CLIENT_CA to a CA bundle. The server will call RequireAndVerifyClientCert, rejecting any connection that does not present a valid client certificate signed by that CA.
export TLS_CERT_FILE=/etc/mllp/server.crt
export TLS_KEY_FILE=/etc/mllp/server.key
export TLS_CLIENT_CA=/etc/mllp/client-ca.crt
export TLS_MIN_VERSION=1.2
Logging
| Environment Variable | Default | Description |
|---|---|---|
LOG_LEVEL | info | Minimum log level. Accepted values: debug, info, warn, error. |
LOG_OUTPUT | stdout | Log destination. Accepted values: stdout, file, console. |
LOG_FILE_PATH | /var/log/mllp/mllp.log | Path to the log file. Used only when LOG_OUTPUT=file. |
LOG_BUFFER_SIZE | 10000 | Capacity of the in-process log ring buffer (number of log entries). |
LOG_POLL_INTERVAL | 10ms | How often the background log writer drains the ring buffer. |
Logs are structured JSON. The console output mode formats logs for human-readable terminal display.
Send SIGUSR1 to cycle the log level at runtime without restarting. The level advances through debug → info → warn → error → debug.
Send SIGHUP to rotate the log file when LOG_OUTPUT=file.
Shutdown
| Environment Variable | Default | Description |
|---|---|---|
SHUTDOWN_TIMEOUT | 30s | Maximum time to wait for in-flight connections to drain after a SIGTERM or SIGINT is received. |
PRE_SHUTDOWN_DELAY | 0 | Time to sleep before beginning connection drain. Use this to allow Kubernetes load balancer rules to propagate. |
Kubernetes load balancer propagation. When a pod receives SIGTERM, the Kubernetes control plane starts removing it from Service endpoints. However, kube-proxy and cloud load balancers may continue sending new connections for a few seconds while the routing tables converge. Setting PRE_SHUTDOWN_DELAY=5s keeps the server listening during that window, preventing connection resets. The typical value is 3s–10s depending on your cluster.
export SHUTDOWN_TIMEOUT=30s
export PRE_SHUTDOWN_DELAY=5s
Observability
Metrics are exported via OpenTelemetry over gRPC (OTLP). When OTEL_EXPORTER_OTLP_ENDPOINT is not set, a no-op meter is used and no metrics are emitted.
| Environment Variable | Default | Description |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | (none) | OTLP gRPC endpoint, e.g. otel-collector:4317. Enables metrics export. |
OTEL_SERVICE_NAME | (none) | Service name reported in metric attributes. |
Metrics are exported every 10 seconds. The gRPC connection is unencrypted (insecure); use a local collector sidecar or a service mesh for encryption.
Instruments emitted:
| Metric | Type | Description |
|---|---|---|
mllp.connections.active | UpDownCounter | Current open connections. |
mllp.connections.total | Counter | Cumulative connections accepted. |
mllp.messages.processed | Counter | Messages processed, labelled by ack_code (AA, AE, AR). |
mllp.message.duration | Histogram (s) | Message processing latency. |
mllp.bytes.received | Counter | Total bytes received. |
mllp.bytes.sent | Counter | Total bytes sent. |
Pod Identity
These variables are consumed automatically when running in Kubernetes. They are used to populate the instance_id field in structured log output.
| Environment Variable | Default | Description |
|---|---|---|
POD_NAME | (none) | Kubernetes pod name. Used as instance_id in logs. |
POD_NAMESPACE | (none) | Kubernetes namespace. Available for log correlation. |
HOSTNAME | (OS hostname) | Fallback when POD_NAME is not set. |
Inject these from the Downward API in your Deployment:
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
Connectors
Connectors forward HL7 messages to downstream systems. They are defined in a YAML file and loaded via two environment variables:
| Environment Variable | Default | Description |
|---|---|---|
CONNECTORS_CONFIG | config.yaml | Path to the connector configuration file. Server starts without connectors if the file is absent. |
OUTBOX_DB_PATH | outbox.db | Path to the outbox database file. Used for durable, at-least-once delivery. |
If CONNECTORS_CONFIG points to a non-existent file the server starts normally without any connectors configured.
Environment variable interpolation
Values in the YAML file may reference environment variables using ${VAR_NAME} syntax. All referenced variables must be set at startup — a missing variable is a fatal error.
connectors:
- name: ehr-api
type: http
url: ${EHR_API_URL}
headers:
Authorization: "Bearer ${EHR_API_TOKEN}"
Connector types
| Type | Description |
|---|---|
http | POST the raw HL7 message to an HTTP/HTTPS endpoint. |
mllp | Forward the raw MLLP frame to another MLLP server. |
postgres | Insert the message into a PostgreSQL table. |
kafka | Publish the message to a Kafka topic. |
Full connector schema
connectors:
- name: <string> # Required. Unique connector name.
type: <http|mllp|postgres|kafka> # Required. Connector type.
disabled: false # Optional. Set true to exclude from routing.
filter:
"" # Optional. CEL expression; connector receives the
# message only when the expression evaluates to true.
# Empty string matches all messages.
# HTTP connector fields
url: "" # HTTP/HTTPS endpoint URL.
timeout: 0s # Per-request timeout (0 = no timeout).
headers: {} # Key-value map of request headers.
max_idle_conns: 0 # Maximum idle connections in the pool.
max_idle_conns_per_host: 0 # Maximum idle connections per host.
idle_conn_timeout: 0s # How long an idle connection is kept alive.
dial_timeout: 0s # TCP dial timeout.
keep_alive: 0s # TCP keep-alive interval for HTTP connections.
tls_insecure_skip_verify: false # Disable TLS verification (not for production).
# MLLP connector fields
address: "" # host:port of the downstream MLLP server.
# Database connector fields (postgres)
dsn: "" # PostgreSQL connection string.
table: "" # Target table name.
column: "" # Target column name.
# Kafka connector fields
brokers: [] # List of broker addresses (host:port).
topic: "" # Kafka topic name.
retry:
max_attempts: 5 # Maximum delivery attempts before moving to DLQ.
initial_delay: 1s # Delay before the first retry.
max_delay: 5m # Maximum delay between retries (exponential backoff cap).
poll_interval: 100ms # How often the outbox worker polls for pending messages.
dead_letter:
disabled:
false # Set true to discard messages that exceed max_attempts
# instead of writing them to the dead letter queue.
Outbox and delivery guarantees
Each connector has its own outbox queue. Messages are written to the outbox atomically before the server sends the ACK back to the sender. A background worker delivers messages to the downstream system and retries on failure using exponential backoff up to retry.max_delay.
Messages that exceed retry.max_attempts are moved to the dead letter queue (DLQ) unless retry.dead_letter.disabled: true, in which case they are discarded.
CEL Validation
Validation rules are defined in the connector config file under a top-level rules key (or as part of the server configuration). Each rule is a CEL expression that must evaluate to true for the message to be accepted. Rules are evaluated in order and short-circuit on the first failure.
rules:
- name: require-patient-id
expression: pid.id != ""
message: "PID-3.1 (patient ID) is required"
- name: require-adt-or-oru
expression: msh.msg_type == "ADT" || msh.msg_type == "ORU"
message: "Only ADT and ORU message types are accepted"
- name: require-observation-value
expression: obx_list.all(o, o.value != "")
message: "All OBX segments must have a value (OBX-5)"
A rule failure returns an AR (Application Reject) ACK to the sender. A rule evaluation error (type mismatch, bad expression) returns an AE (Application Error) ACK.
Rules are compiled at startup. An invalid expression or a rule whose estimated CEL cost exceeds the safety limit (1000) is a fatal error.
CEL variables
The following variables are available in every rule expression. Missing segments produce empty maps rather than errors, so expressions like pid.id != "" are safe even when the PID segment is absent.
msh — map(string, string)
First MSH segment of the message.
| Key | HL7 Field | Description |
|---|---|---|
msg_type | MSH-9.1 | Message type code (e.g. ADT, ORU). |
trigger | MSH-9.2 | Trigger event code (e.g. A01, R01). |
sending_app | MSH-3 | Sending application. |
sending_fac | MSH-4 | Sending facility. |
receiving_app | MSH-5 | Receiving application. |
receiving_fac | MSH-6 | Receiving facility. |
control_id | MSH-10 | Message control ID. |
version | MSH-12 | HL7 version (e.g. 2.5). |
pid — map(string, string)
First PID segment of the message.
| Key | HL7 Field | Description |
|---|---|---|
id | PID-3.1 | Patient identifier. |
name | PID-5 | Patient name. |
dob | PID-7 | Date of birth. |
sex | PID-8 | Administrative sex. |
ssn | PID-19 | Social security number. |
country | PID-11.6 | Country code. |
pv1 — map(string, string)
First PV1 segment of the message.
| Key | HL7 Field | Description |
|---|---|---|
patient_class | PV1-2 | Patient class (e.g. I, O, E). |
assigned_location | PV1-3 | Assigned patient location. |
attending_doctor | PV1-7 | Attending doctor. |
admit_datetime | PV1-44 | Admit date/time. |
obx — map(string, string)
First OBX segment only. Useful for simple single-observation messages.
| Key | HL7 Field | Description |
|---|---|---|
value_type | OBX-2 | Value type (e.g. NM, ST, CWE). |
identifier | OBX-3 | Observation identifier. |
value | OBX-5 | Observation value. |
unit | OBX-6 | Units. |
status | OBX-11 | Observation result status. |
obx_list — list(map(string, string))
All OBX segments in the message as a list. Each entry has the same keys as obx. Use this for multi-observation messages.
# All observations must have a non-empty value
obx_list.all(o, o.value != "")
# At least one observation with identifier "8867-4" (heart rate)
obx_list.exists(o, o.identifier.startsWith("8867-4"))
Full Example
A complete config.yaml combining connector routing and validation rules:
connectors:
- name: ehr-ingest
type: http
url: ${EHR_API_URL}
timeout: 10s
headers:
Authorization: "Bearer ${EHR_API_TOKEN}"
Content-Type: "application/hl7-v2"
retry:
max_attempts: 5
initial_delay: 1s
max_delay: 5m
dead_letter:
disabled: false
- name: adt-audit
type: http
url: https://audit.internal/hl7
filter: msh.msg_type == "ADT"
retry:
max_attempts: 3
initial_delay: 2s
max_delay: 1m
- name: lab-results
type: kafka
filter: msh.msg_type == "ORU"
brokers:
- kafka-broker-1:9092
- kafka-broker-2:9092
topic: hl7-lab-results
retry:
max_attempts: 10
initial_delay: 500ms
max_delay: 10m
- name: legacy-lis
type: mllp
address: legacy-lis.internal:2575
filter: msh.msg_type == "ORU" && msh.sending_fac == "LAB-WEST"
- name: archive
type: postgres
dsn: ${ARCHIVE_DSN}
table: hl7_messages
column: raw
rules:
- name: require-control-id
expression: msh.control_id != ""
message: "MSH-10 (message control ID) is required"
- name: require-patient-id
expression: pid.id != ""
message: "PID-3.1 (patient ID) is required"
- name: require-hl7-version
expression: msh.version == "2.3" || msh.version == "2.4" || msh.version == "2.5" || msh.version == "2.5.1"
message: "HL7 version must be 2.3, 2.4, 2.5, or 2.5.1"
- name: inpatient-requires-location
expression: pv1.patient_class != "I" || pv1.assigned_location != ""
message: "Inpatient encounters (PV1-2=I) must include an assigned location (PV1-3)"
Corresponding environment variables:
# Server
export LISTEN_ADDR=":2575"
export IDLE_TIMEOUT="30s"
export WRITE_TIMEOUT="5s"
export FRAME_TIMEOUT="60s"
export MAX_CONNECTIONS="100"
# TLS
export TLS_CERT_FILE="/etc/mllp/server.crt"
export TLS_KEY_FILE="/etc/mllp/server.key"
export TLS_CLIENT_CA="/etc/mllp/client-ca.crt"
# Logging
export LOG_LEVEL="info"
export LOG_OUTPUT="stdout"
# Shutdown (Kubernetes)
export SHUTDOWN_TIMEOUT="30s"
export PRE_SHUTDOWN_DELAY="5s"
# Observability
export OTEL_EXPORTER_OTLP_ENDPOINT="otel-collector:4317"
export OTEL_SERVICE_NAME="mllp-server"
# Connectors
export CONNECTORS_CONFIG="/etc/mllp/config.yaml"
export OUTBOX_DB_PATH="/data/outbox.db"
# Application secrets (referenced in config.yaml)
export EHR_API_URL="https://ehr.example.com/hl7"
export EHR_API_TOKEN="eyJ..."
export ARCHIVE_DSN="postgres://mllp:secret@db:5432/hl7?sslmode=require"