Parameters, mappings & outputs
Make a template reusable.
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 (Parameters section)Parameters:EnvName:Type: StringAllowedValues: [dev, staging, prod] # reject anything off this menuDefault: devDescription: Which environment this stack servesInstanceType:Type: StringDefault: t3.microKeyName:Type: AWS::EC2::KeyPair::KeyName # dropdown + existence checkDbPassword:Type: StringNoEcho: true # keep out of console + eventsMinLength: 12AllowedPattern: '[A-Za-z0-9!@#$%^&*]+'ConstraintDescription: at least 12 chars, letters/digits/symbols only
# DB_PASSWORD is read from your shell environmentaws cloudformation deploy \--template-file template.yaml \--stack-name web-dev \--parameter-overrides EnvName=dev InstanceType=t3.small \KeyName=web-dev-key DbPassword="$DB_PASSWORD"
Waiting for changeset to be created..Waiting for stack create/update to completeSuccessfully 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.
aws cloudformation describe-stacks --stack-name web-dev \--query 'Stacks[0].Parameters'
[{"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.
aws cloudformation deploy \--template-file template.yaml \--stack-name web-test \--parameter-overrides EnvName=qa KeyName=web-dev-key DbPassword="$DB_PASSWORD"
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 (Mappings section + one resource that reads it)Mappings:RegionAmi: # top-level keyus-east-1: # second-level key, matched to AWS::RegionAmi: ami-0abcdef1234567890eu-west-1:Ami: ami-0fedcba9876543210Resources:Web:Type: AWS::EC2::InstanceProperties:# !FindInMap [ MapName, TopKey, SecondKey ] -> valueImageId: !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 (Outputs section)Outputs:WebUrl:Description: Public endpoint of the appValue: !Sub 'http://${Web.PublicDnsName}'VpcId:Description: VPC this stack createdValue: !Ref VpcExport: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.
aws cloudformation describe-stacks --stack-name web-dev \--query 'Stacks[0].Outputs'
[{"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):
URL=$(aws cloudformation describe-stacks --stack-name web-dev \--query "Stacks[0].Outputs[?OutputKey=='WebUrl'].OutputValue" \--output text)echo "$URL"
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.
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.
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:
aws cloudformation validate-template \--template-body file://template.yaml \--query 'Parameters[].ParameterKey'
["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.