Packer configuration that builds a public AWS AMI running a Mumble (Murmur) voice server with automatic TLS via Let's Encrypt DNS-01 challenges through Route53.
This image bakes in everything a Mumble server needs and defers all domain-specific and secret configuration to first boot via cloud-init. Launch it for any domain by supplying values through EC2 user-data.
Key design points:
- Ubuntu 26.04 LTS (Resolute), arm64/Graviton base, rebuilt from the newest Canonical image at each bake.
- TLS via DNS-01, not HTTP-01. certbot obtains and renews certificates by writing TXT records to Route53 using the instance's IAM role — no port 80, no stored AWS keys, no inbound reachability requirement for cert issuance.
- SSM-only access. No SSH key pair; reach the instance through AWS Session Manager. IMDSv2 required, root volume encrypted.
- Unattended renewal. certbot's systemd timer renews the certificate and a deploy-hook redeploys it to Mumble automatically — no manual cert copying.
- Runtime configuration. Domain, Let's Encrypt email, SuperUser password, welcome message, and server password are all injected at first boot.
Baked into the image:
mumble-server(installed, disabled until first boot obtains a cert)certbotandpython3-certbot-dns-route53- A certbot deploy-hook that places renewed certs where Mumble can read them
- A Mumble config template (cert paths fixed; runtime values slotted in)
- A first-boot script (
/usr/local/sbin/mumble-first-boot.sh) driven by cloud-init - certbot renewal timer (enabled, dormant until a cert exists)
- Packer >= 1.14.0
- AWS credentials configured (env vars, shared config, or instance profile)
- A build VPC/subnet with outbound internet access
- An IAM instance profile for the build with
AmazonSSMManagedInstanceCore(Packer connects via Session Manager — see Build IAM)
Copy the example vars file, fill in your build networking, and build:
cp example.pkrvars.hcl build.auto.pkrvars.hcl # gitignored; edit values
packer init .
packer fmt -check .
packer validate .
packer build .The build prints the resulting AMI ID on completion. See variables.pkr.hcl for
all available build variables.
The build instance connects via AWS Session Manager (no SSH keys), so it needs an instance profile with SSM permissions. Create this once in your account before building:
resource "aws_iam_role" "packer_build" {
name = "PackerBuildRole"
description = "Role assumed by Packer build instances for SSM connectivity"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "packer_build_ssm" {
role = aws_iam_role.packer_build.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "packer_build" {
name = aws_iam_role.packer_build.name
role = aws_iam_role.packer_build.name
}Pass the instance profile name to the build via the iam_instance_profile
variable (see example.pkrvars.hcl). The identity you run Packer with (your AWS
credentials) also needs permissions to launch instances, create AMIs and
snapshots, and manage temporary build resources — see the
Packer AWS documentation
for the minimal build policy.
Building via CI? GitHub Actions can assume a role via OIDC instead of stored credentials. That setup (an OIDC provider plus a scoped role trusted by your repo) is account-specific and left to the consumer; see HashiCorp and GitHub's OIDC documentation.
At first boot, cloud-init (from the instance's user-data) must write
/etc/red-mumble/first-boot.env and invoke the first-boot script. The env file
supports:
| Variable | Required | Description |
|---|---|---|
MUMBLE_DOMAIN |
yes | FQDN for the server; also the cert domain. |
LE_EMAIL |
yes | Let's Encrypt registration email. |
MUMBLE_SUPERUSER_PASSWORD |
yes | Mumble SuperUser password (hashed into the DB on first start, then stripped from disk). |
MUMBLE_WELCOME_TEXT |
no | Welcome message shown to clients. Supports Mumble HTML (<br />, <b>). Defaults to a generic message. |
MUMBLE_SERVER_PASSWORD |
no | Password clients must enter to join. Empty means an open server. |
The instance's IAM role must permit Route53 record changes on the domain's hosted zone for the DNS-01 challenge.
This image pairs with the
terraform-aws-red-instance
module (>= v2.2.0), which provides the enable_route53_policy and user_data
inputs this image needs.
A cloud-config user-data template (mumble-user-data.yaml.tftpl) writes the env
file and runs first-boot:
#cloud-config
write_files:
- path: /etc/red-mumble/first-boot.env
owner: root:root
permissions: "0600"
content: |
MUMBLE_DOMAIN=${mumble_domain}
LE_EMAIL=${le_email}
MUMBLE_SUPERUSER_PASSWORD="${superuser_password}"
MUMBLE_WELCOME_TEXT="${welcome_text}"
MUMBLE_SERVER_PASSWORD="${server_password}"
runcmd:
- /usr/local/sbin/mumble-first-boot.shAnd the module call:
module "mumble" {
source = "git::https://github.com/RussellGilmore/terraform-aws-red-instance.git?ref=v2.2.0"
project_name = "red-space"
instance_name = "Mumble"
instance_type = "t4g.micro"
ami_name = "red-mumble-*" # matches the Packer ami_name_prefix
ami_owner = var.mumble_ami_owner # your account ID (you built the AMI)
volume_size = 16
# Standalone: the module provisions its own public VPC.
create_vpc = true
availability_zone = "us-east-1f"
allocate_eip = true
# Mumble uses 64738 TCP + UDP. No SSH (SSM-only), no port 80 (DNS-01).
ingress_rules = [
{
description = "Mumble TCP"
from_port = 64738
to_port = 64738
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "Mumble UDP"
from_port = 64738
to_port = 64738
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
}
]
# Route53 for DNS-01 cert issuance and renewal.
enable_route53_policy = true
route53_zone_id = var.mumble_zone_id
# Rendered user-data carrying domain and secrets.
user_data = templatefile("${path.module}/mumble-user-data.yaml.tftpl", {
mumble_domain = "mumble.example.com"
le_email = var.le_email
superuser_password = var.mumble_superuser_password
welcome_text = var.mumble_welcome_text
server_password = var.mumble_server_password
})
# Public DNS A record for the server.
enable_public_dns = true
apex_domain = "example.com"
dns_name = "mumble.example.com"
}Secret values (le_email, mumble_superuser_password,
mumble_server_password) are supplied via Terraform variables sourced from a
local, gitignored .env (as TF_VAR_*) at apply time, so they never enter
source control.