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:
-
Declare the root variable in
src/variables.secrets.tf. It must besensitive = truewithdefault = null, and its name must be the lowercase form of the container env var it backs (SMTP_PASSWORD→smtp_password): -
Register it in
local.secretsinsrc/locals.tf: -
Reference it from tfvars by env var name only. The
secure_environment_variable_secretslist on a container names the env vars that should be injected fromlocal.secrets. No secret key is accepted — the module looks uplower(<env var name>)inlocal.secrets, so the mapping is fixed by the variable declared in step 1. See Container Instances — Secure environment variables from secrets. -
Supply the value at runtime, never in source:
In Azure Pipelines, map it from a pipeline secret the same way
TF_VAR_windows_passwordis handled inbuild/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.