MLLP Server

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 VariableDefaultDescription
LISTEN_ADDR:2575TCP address to bind. Use host:port or :port.
IDLE_TIMEOUT30sMaximum time a connection may be idle between MLLP frames before it is closed.
WRITE_TIMEOUT5sMaximum time allowed to write an ACK response back to the sender.
FRAME_TIMEOUT60sMaximum time to receive a complete MLLP frame after the Start Block byte is seen.
MAX_FRAME_SIZE2097152Maximum MLLP frame payload size in bytes. Default is 2 MB (2097152).
MAX_CONNECTIONS0Maximum concurrent connections. 0 means unlimited.
KEEP_ALIVE_PERIOD60sTCP keep-alive interval. Set to 0 to disable keep-alive.
CONNECT_TIMEOUT10sTLS 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 VariableDefaultDescription
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_VERSION1.2Minimum 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 VariableDefaultDescription
LOG_LEVELinfoMinimum log level. Accepted values: debug, info, warn, error.
LOG_OUTPUTstdoutLog destination. Accepted values: stdout, file, console.
LOG_FILE_PATH/var/log/mllp/mllp.logPath to the log file. Used only when LOG_OUTPUT=file.
LOG_BUFFER_SIZE10000Capacity of the in-process log ring buffer (number of log entries).
LOG_POLL_INTERVAL10msHow 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 VariableDefaultDescription
SHUTDOWN_TIMEOUT30sMaximum time to wait for in-flight connections to drain after a SIGTERM or SIGINT is received.
PRE_SHUTDOWN_DELAY0Time 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 3s10s 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 VariableDefaultDescription
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:

MetricTypeDescription
mllp.connections.activeUpDownCounterCurrent open connections.
mllp.connections.totalCounterCumulative connections accepted.
mllp.messages.processedCounterMessages processed, labelled by ack_code (AA, AE, AR).
mllp.message.durationHistogram (s)Message processing latency.
mllp.bytes.receivedCounterTotal bytes received.
mllp.bytes.sentCounterTotal 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 VariableDefaultDescription
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 VariableDefaultDescription
CONNECTORS_CONFIGconfig.yamlPath to the connector configuration file. Server starts without connectors if the file is absent.
OUTBOX_DB_PATHoutbox.dbPath 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

TypeDescription
httpPOST the raw HL7 message to an HTTP/HTTPS endpoint.
mllpForward the raw MLLP frame to another MLLP server.
postgresInsert the message into a PostgreSQL table.
kafkaPublish 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.

mshmap(string, string)

First MSH segment of the message.

KeyHL7 FieldDescription
msg_typeMSH-9.1Message type code (e.g. ADT, ORU).
triggerMSH-9.2Trigger event code (e.g. A01, R01).
sending_appMSH-3Sending application.
sending_facMSH-4Sending facility.
receiving_appMSH-5Receiving application.
receiving_facMSH-6Receiving facility.
control_idMSH-10Message control ID.
versionMSH-12HL7 version (e.g. 2.5).

pidmap(string, string)

First PID segment of the message.

KeyHL7 FieldDescription
idPID-3.1Patient identifier.
namePID-5Patient name.
dobPID-7Date of birth.
sexPID-8Administrative sex.
ssnPID-19Social security number.
countryPID-11.6Country code.

pv1map(string, string)

First PV1 segment of the message.

KeyHL7 FieldDescription
patient_classPV1-2Patient class (e.g. I, O, E).
assigned_locationPV1-3Assigned patient location.
attending_doctorPV1-7Attending doctor.
admit_datetimePV1-44Admit date/time.

obxmap(string, string)

First OBX segment only. Useful for simple single-observation messages.

KeyHL7 FieldDescription
value_typeOBX-2Value type (e.g. NM, ST, CWE).
identifierOBX-3Observation identifier.
valueOBX-5Observation value.
unitOBX-6Units.
statusOBX-11Observation result status.

obx_listlist(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"