CoursesAWS CloudFormationParameters, mappings & outputs

Parameters, mappings & outputs

Make a template reusable.

Beginner12 min · lesson 3 of 12

A form letter with the name already inked into the greeting works exactly once, for exactly one reader. Type real values straight into a CloudFormation template (a text file that describes cloud infrastructure for Amazon Web Services, AWS) and you have built the same dead end: it deploys your dev environment and nothing else. Parameters, mappings, and outputs are the three features that turn that one-shot file into a blank form anyone can fill and reuse.

Parameters are the blanks the caller fills in at deploy time. Mappings are a lookup table baked into the template so it can pick the right value for wherever it runs, with nobody typing anything. Outputs are the receipt the stack (the live set of resources CloudFormation builds from one template) hands back once it exists: the web addresses, identifiers, and resource names that other people, other stacks, and your deploy pipeline need to take their next step. One template, deployed to dev, staging, and production unchanged.

Parameters: The Blanks The Caller Fills

A parameter is a labelled blank on a form. The form ships with the blank empty, and whoever submits it writes the value in. In a template, parameters live in their own top-level Parameters section, and CloudFormation asks for them (or reads them from your command) every single time you create or update the stack.

Each parameter has a Type. The plain ones are String and Number. CommaDelimitedList takes the text 'a,b,c' and hands your template a real list. The interesting ones are the AWS-specific types. Declare a parameter as AWS::EC2::KeyPair::KeyName (the name of an SSH, Secure Shell, login key already stored in your account, where EC2 is Elastic Compute Cloud, AWS's virtual servers) and two things happen: the web console draws a dropdown of the keys that actually exist, and CloudFormation refuses a name that does not, before it touches a single resource. Same story for AWS::EC2::VPC::Id and AWS::EC2::Subnet::Id (identifiers for a private network and a slice of one). The type is a fence at the gate instead of a crash halfway through the build.

You can tighten the blank further. AllowedValues is a fixed menu. AllowedPattern is a regular expression (a pattern the text must match). MinLength, MaxLength, MinValue, and MaxValue set bounds. Add ConstraintDescription and a rejection comes back in plain English instead of a raw regex. A Default value makes the parameter optional. NoEcho: true masks the value as **** in the console, in describe-stacks, and in the stack's events. You read a parameter back inside the template with Ref, one of CloudFormation's intrinsic functions (the built-in helpers a template calls to look values up; the whole family is its own lesson, cf-functions). Every required parameter is one more thing a human has to remember and get right, so keep the list short. A template can hold up to 200 of them. A good one asks for a handful.

template.yaml
# template.yaml (Parameters section)
Parameters:
EnvName:
Type: String
AllowedValues: [dev, staging, prod] # reject anything off this menu
Default: dev
Description: Which environment this stack serves
InstanceType:
Type: String
Default: t3.micro
KeyName:
Type: AWS::EC2::KeyPair::KeyName # dropdown + existence check
DbPassword:
Type: String
NoEcho: true # keep out of console + events
MinLength: 12
AllowedPattern: '[A-Za-z0-9!@#$%^&*]+'
ConstraintDescription: at least 12 chars, letters/digits/symbols only
terminal
# DB_PASSWORD is read from your shell environment
aws cloudformation deploy \
--template-file template.yaml \
--stack-name web-dev \
--parameter-overrides EnvName=dev InstanceType=t3.small \
KeyName=web-dev-key DbPassword="$DB_PASSWORD"
output
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - web-dev

On a later update, leave a parameter out of --parameter-overrides and CloudFormation keeps whatever value the stack already has. So --parameter-overrides InstanceType=t3.medium bumps the instance size and quietly reuses the existing EnvName, KeyName, and DbPassword. That is convenient, and it is a trap: the running value is not written in the template, so the only honest way to know what production is actually using is to ask the stack itself.

terminal
aws cloudformation describe-stacks --stack-name web-dev \
--query 'Stacks[0].Parameters'
output
[
{
"ParameterKey": "InstanceType",
"ParameterValue": "t3.medium"
},
{
"ParameterKey": "EnvName",
"ParameterValue": "dev"
},
{
"ParameterKey": "KeyName",
"ParameterValue": "web-dev-key"
},
{
"ParameterKey": "DbPassword",
"ParameterValue": "****"
}
]

Notice DbPassword comes back as ****, because it was declared NoEcho. Hold that thought. Notice too that the constraints do their job at the gate. Hand the stack a value outside AllowedValues and the deploy dies while CloudFormation is still assembling the change set (its preview of exactly what a deploy would add, change, or remove), before a single resource is created or touched.

terminal
aws cloudformation deploy \
--template-file template.yaml \
--stack-name web-test \
--parameter-overrides EnvName=qa KeyName=web-dev-key DbPassword="$DB_PASSWORD"
output
An error occurred (ValidationError) when calling the CreateChangeSet operation: Parameter 'EnvName' must be one of AllowedValues [dev, staging, prod]

Mappings: The Template's Own Lookup Table

A mapping is the laminated conversion chart taped inside a kitchen cupboard door. Nobody types anything into it. You find the row and the column, and read the number where they cross. A CloudFormation mapping is a fixed two-level table stored inside the template: a top-level key, a second-level key, and the value that sits at their intersection.

The classic job is region-specific data. The same operating-system image (an AMI, Amazon Machine Image, the disk snapshot a virtual server boots from) has a different identifier in every AWS region. Hardcode one and your template only works in one city. Map each region to its image identifier instead, and let the Fn::FindInMap function resolve it against AWS::Region, a pseudo-parameter (a value AWS fills in for you) that always equals the region the stack is deploying into. The caller supplies nothing. The template reads its own chart and adapts to wherever it runs.

template.yaml
# template.yaml (Mappings section + one resource that reads it)
Mappings:
RegionAmi: # top-level key
us-east-1: # second-level key, matched to AWS::Region
Ami: ami-0abcdef1234567890
eu-west-1:
Ami: ami-0fedcba9876543210
Resources:
Web:
Type: AWS::EC2::Instance
Properties:
# !FindInMap [ MapName, TopKey, SecondKey ] -> value
ImageId: !FindInMap [RegionAmi, !Ref 'AWS::Region', Ami]
InstanceType: !Ref InstanceType

Mappings are right for a small, frozen matrix: region to image, environment to instance size, account to logging bucket. They are frozen on purpose. The keys and values are literal text, decided the moment you save the file. You cannot compute them, and you cannot fetch them from an interface at deploy time. If a region is missing from the chart, FindInMap fails the deploy rather than guessing (templates that turn on the AWS::LanguageExtensions transform can hand FindInMap a DefaultValue to fall back on). When the data is genuinely dynamic, a mapping is the wrong tool. Use a parameter, or better, a parameter typed AWS::SSM::Parameter::Value<String> that pulls the current value out of AWS Systems Manager Parameter Store (a shared store for configuration values) at deploy time.

Outputs: The Receipt The Stack Returns

When you check a coat, you get a numbered ticket. You could not have printed that ticket before handing the coat over, because the number only exists once the coat is on the rack. Outputs are that ticket. They are the values you cannot know until the resources exist: the server's public DNS name (Domain Name System, the human-readable web address), a VPC's identifier (Virtual Private Cloud, your private slice of the AWS network), a queue's ARN (Amazon Resource Name, the long unique string AWS stamps on every resource).

Each output has a Value, almost always a Ref (give me this resource's main identifier) or an Fn::GetAtt (give me one specific attribute of it), plus an optional Description. That alone earns its keep: an output shows up in the console and, more to the point, you can read it from the command line. That is exactly how a continuous-integration pipeline (the automated system that builds and ships your code, CI for short) grabs the fresh URL to smoke-test (hit it once to confirm the app answers) or the ARN to wire into its next step.

template.yaml
# template.yaml (Outputs section)
Outputs:
WebUrl:
Description: Public endpoint of the app
Value: !Sub 'http://${Web.PublicDnsName}'
VpcId:
Description: VPC this stack created
Value: !Ref Vpc
Export:
Name: !Sub '${AWS::StackName}-VpcId' # importable by other stacks

Add an Export block and the output becomes importable by other stacks in the same account and region with Fn::ImportValue. That is the backbone of splitting a big system into a network stack, a database stack, and an app stack that reference each other (covered in cf-nested). The export name has to be unique across the whole account and region, which is why people prefix it with the stack name. Without an export, the output is still visible in the console and readable from the command line, which covers most day-to-day needs.

terminal
aws cloudformation describe-stacks --stack-name web-dev \
--query 'Stacks[0].Outputs'
output
[
{
"OutputKey": "WebUrl",
"OutputValue": "http://ec2-203-0-113-25.compute-1.amazonaws.com",
"Description": "Public endpoint of the app"
},
{
"OutputKey": "VpcId",
"OutputValue": "vpc-0a1b2c3d4e5f67890",
"Description": "VPC this stack created",
"ExportName": "web-dev-VpcId"
}
]

A pipeline rarely wants the whole blob. Narrow the query to one value and strip the JSON quoting with --output text, and the URL drops straight into a shell variable, ready to hand to curl (a command-line tool that makes web requests):

terminal
URL=$(aws cloudformation describe-stacks --stack-name web-dev \
--query "Stacks[0].Outputs[?OutputKey=='WebUrl'].OutputValue" \
--output text)
echo "$URL"
output
http://ec2-203-0-113-25.compute-1.amazonaws.com

Exports come with a catch worth knowing before you lean on them. Once another stack imports your exported output with Fn::ImportValue, you cannot delete or change that export while the import still exists. Try to tear down the network stack and CloudFormation refuses, with something like 'Export web-dev-VpcId cannot be deleted as it is in use by app-prod.' Plan your teardown order from the importers inward, or a 'temporary' stack ends up bolted to production by a dependency you forgot you created.

Where a reusable template's values come from and go
Parameters (caller fills)
EnvName
dev / staging / prod
InstanceType
t3.small
KeyName
validated at the gate
Mappings (template decides)
RegionAmi
region -> image id
FindInMap
resolves AWS::Region
Outputs (stack returns)
WebUrl
read by the pipeline
VpcId
exported to other stacks
Parameters and mappings feed values in at deploy time; outputs hand values back after the stack exists.

What An Attacker Reads, And What You Never Write

Every value in this lesson is readable by anyone with view access to the stack. describe-stacks returns the parameters and outputs; get-template returns the entire file, mappings and all. To someone who has landed a read-only role in your account, that is a free floor plan: which networks exist, which image identifiers you run, which resource names to aim at next. You cannot hide the floor plan from people who can read the stack, so control who can read it. In IAM (Identity and Access Management, the AWS service that decides who may call which action), grant cloudformation:DescribeStacks and cloudformation:GetTemplate to the humans and pipelines that genuinely need them, and to no one else.

Secrets are the sharp edge. A parameter value you did not mark NoEcho is there for anyone to read: describe-stacks hands it back verbatim, and the create-or-update call that set it is recorded in CloudTrail (the log that captures every API call in the account), value included. Anyone who can read those can read the secret, long after the deploy finished and long after you have forgotten it was ever a parameter. Passwords and keys should never arrive as ordinary parameters in the first place.

NoEcho masks, it does not encrypt
NoEcho: true hides a parameter in the console field and stack events, and describe-stacks shows it as ****. That is display masking on one field, nothing more. The real value still travels in plaintext to every resource that references it, and the masking does not follow the value anywhere else. Echo that parameter into an Output, or into a resource's Metadata, and NoEcho no longer applies: the secret becomes plain text that anyone with describe-stacks can read. Do not pass real passwords or keys as parameters at all. Use a dynamic reference like '{{resolve:ssm-secure:/db/password}}' or '{{resolve:secretsmanager:prod/db:SecretString:password}}' so only a resolver pointer sits in the file, and CloudFormation fetches the actual secret from Systems Manager or Secrets Manager at deploy time.
Quick check
01A template marks DbPassword with NoEcho: true, then adds an Output whose Value is !Ref DbPassword so the deploy pipeline can grab it. What is the real consequence?
Incorrect — NoEcho only hides the parameter's own display; it does not follow the value into outputs or metadata.
Correct — Echoing a NoEcho parameter into an output strips the masking, and the value sits there in plain text for any reader.
Incorrect — The deploy succeeds; there is no such block, which is exactly why this leak is so easy to miss.
Incorrect — Outputs are not encrypted; describe-stacks returns them as plain text to any reader.
02Which statement correctly describes a Mappings table and the Fn::FindInMap function that reads it?
Incorrect — nobody types into a mapping; its values are baked into the template, unlike parameters.
Incorrect — a mapping is static text; fetching a live value at deploy time is what an SSM parameter type does, not FindInMap.
Incorrect — by default a missing key makes FindInMap fail the deploy rather than guess a value.
Correct — mappings are frozen the moment you save; for genuinely dynamic data you use a parameter, such as one typed AWS::SSM::Parameter::Value<String>, instead.
03A network stack exports its VpcId (the identifier of a Virtual Private Cloud), and an app stack imports it with Fn::ImportValue. You now try to delete the network stack. What happens?
Incorrect — CloudFormation does not let you delete an export still in use, so the deletion does not proceed.
Correct — an output cannot be deleted or changed while another stack still imports it, so you must tear down the importer first.
Incorrect — importing does not merge lifecycles; it instead pins the export so the exporter cannot be torn down.
Incorrect — CloudFormation blocks the exporting stack's deletion outright rather than deleting it and rolling back the importer.

Prove It Before You Call It Reusable

A template you have only ever deployed once, in one region, is a claim, not a fact. Prove it. validate-template parses the file and checks its structure without building anything, so malformed YAML (the whitespace-sensitive text format the template is written in) or a reference to a parameter you never declared surfaces in seconds. It also echoes back the parameters it found, a quick sanity check that the form has the blanks you expect:

terminal
aws cloudformation validate-template \
--template-body file://template.yaml \
--query 'Parameters[].ParameterKey'
output
[
"EnvName",
"InstanceType",
"KeyName",
"DbPassword"
]

Then do the real test: deploy the same file, unchanged, into a second region. If FindInMap finds a row there and the stack comes up green, you have a reusable template. If it does not, you have learned that here, before production did. A template that only ever ran in us-east-1 is not reusable yet. It is untested.

Try this

Work through “Prove It Before You Call It Reusable” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: noEcho masks, it does not encrypt. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related