Skip to content

SMTP Forwarder

Overview

The SMTP forwarder is a Postfix relay running in a private Azure Container Instance. It accepts unauthenticated SMTP on port 25 from clients inside the VNet (legacy apps, appliances, and servers that cannot do modern SMTP auth) and forwards mail authenticated over 587/STARTTLS to Azure Communication Services (smtp.azurecomm.net).

VNet clients ──25/SMTP──► smtp-forwarder (Postfix, private ACI) ──587/STARTTLS──► smtp.azurecomm.net (ACS)

It is built from existing modules — no dedicated module exists:

Piece Module Purpose
Postfix relay container_group Runs ghcr.io/sapphire-health/postfix privately in a delegated subnet
Stable endpoint private_dns_a_record A record that auto-resolves the container group's private IP, so clients survive IP changes on redeploy
SMTP credentials ACS (see Communication Services) The SMTP username is created on the communication_service resource; the password is an Entra application client secret

Secrets

The relay needs one secret: the SMTP password (SMTP_PASSWORD env var) used to authenticate to ACS. Secrets are never written in tfvars. The flow is:

  1. Declare the root variable in src/variables.secrets.tf. It must be sensitive = true with default = null, and its name must be the lowercase form of the container env var it backs (SMTP_PASSWORDsmtp_password):

    variable "smtp_password" {
        type        = string
        description = "Entra application client secret used by the SMTP forwarder to authenticate to ACS; set via env var TF_VAR_smtp_password."
        sensitive   = true
        default     = null
    }
    
  2. Register it in local.secrets in src/locals.tf:

    locals {
        secrets = {
            default_password   = var.default_password
            sql_admin_password = var.sql_admin_password
            smtp_password      = var.smtp_password
        }
    }
    
  3. Reference it from tfvars by env var name only. The secure_environment_variable_secrets list on a container names the env vars that should be injected from local.secrets. No secret key is accepted — the module looks up lower(<env var name>) in local.secrets, so the mapping is fixed by the variable declared in step 1. See Container Instances — Secure environment variables from secrets.

  4. Supply the value at runtime, never in source:

    $env:TF_VAR_smtp_password = "<entra-app-client-secret>"
    

    In Azure Pipelines, map it from a pipeline secret the same way TF_VAR_windows_password is handled in build/azure-pipelines.yml.

If an env var is listed in secure_environment_variable_secrets but its lowercase name is missing from local.secrets, terraform plan fails with a key-not-found error — a typo cannot silently deploy a container with an empty password.

The value lands on the container as a secure_environment_variables entry, so Azure does not expose it in the portal, CLI output, or the container group's properties.

Example (dev)

environments/dev/container_group.tfvars:

container_group = {
  smtp-forwarder = {
    resource_group  = "hsw"
    ip_address_type = "Private"
    subnets         = ["hsw.container"]
    container = {
      postfix = {
        image  = "ghcr.io/sapphire-health/postfix:1.11.0"
        cpu    = 1
        memory = 1
        ports = [
          {
            port     = 25
            protocol = "TCP"
          }
        ]
        environment_variables = {
          SMTP_SERVER     = "smtp.azurecomm.net"
          SMTP_PORT       = "587"
          SMTP_USERNAME   = "smtp-forwarder" # SMTP username created on the communication_service resource
          SERVER_HOSTNAME = "smtp-forwarder.sapphirehealth.org"
          SMTP_NETWORKS   = "10.40.44.0/22"  # networks allowed to relay
        }
        secure_environment_variable_secrets = [
          "SMTP_PASSWORD" # resolved from var.smtp_password via local.secrets
        ]
      }
    }
    identity = {
      type = "SystemAssigned"
    }
  }
}

environments/dev/private_dns_a_record.tfvars — the A record references the container group by key, so the record always tracks the current private IP:

private_dns_a_record = {
  smtp-forwarder = {
    name            = "smtp-forwarder"
    resource_group  = "hsw"
    zone            = "sapphire.dev"
    container_group = "smtp-forwarder"
    ttl             = 300
  }
}

Clients then relay through smtp-forwarder.sapphire.dev:25.