Eight modules have built a complete architecture: six subnets, two auto scaling groups, five Lambda functions, four queues, one Aurora cluster, two DynamoDB tables, five buckets, a load balancer, a CloudFront distribution, fourteen alarms and a pipeline that deploys without taking the service down. It all works. And none of it exists in any file: it exists because somebody typed a command one day. This lesson fixes the asymmetry Marta pointed out when closing module 8, using the native AWS tool for the job: CloudFormation, the service that turns a text file into real resources and maintains the relationship between the two.
Cost warning. CloudFormation is free when it manages AWS resources: you pay for the resources, not for the stacks. It only charges for third-party resources and registered private types (0.0009 USD per handler second). The network template creates two NAT gateways: around 0.045 USD/hour each plus traffic, about 65 USD a month if you leave it up. The cleanup section deletes it with one command. Fictitious data.
Contents
- The asymmetry between code and infrastructure
- What infrastructure as code is, and declarative versus imperative
- Anatomy of a template
- Parameters: types, constraints and values from SSM
- Mappings, Conditions and intrinsic functions
- Stacks: lifecycle, states and
ROLLBACK_COMPLETE - Deletion policies and termination protection
- Change sets and the three types of update
- Drift detection
- MercadoFresco's network in a template
- The application layer that imports the outputs
- Several stacks: exports versus SSM
- Nested stacks, modules and custom resources
- Importing existing resources into a stack
- StackSets and deployment from the pipeline
- Best practices,
cfn-lintandcfn-guard - Limits, cost and cleanup
- Common mistakes and tips
- Exercises
- Conclusion
The asymmetry between code and infrastructure
Marta takes stock in one afternoon and writes down four facts:
| Fact | Evidence | Consequence |
|---|---|---|
| Nobody can recreate the environment | There is no document or script describing it | A new region is a project lasting weeks |
| Pre-production is not the same as production | Its SG allows 0.0.0.0/0 on port 22 |
Incidents cannot be reproduced in time |
| There is no record of why | The EventBridge rule has no author and no reason | Nobody dares delete anything |
| The pipeline was created by hand | A JSON on Luis's laptop | What governs the code is not itself governed |
The third one is the most expensive. In module 5 a security group turned up with a rule pointing at an IP range nobody recognised and nobody deleted: it might have belonged to a supplier. That is the real cost of not having infrastructure as code: it is not that you cannot create it; it is that you cannot change it with confidence.
What infrastructure as code is: declarative versus imperative
It consists of describing the infrastructure in text files versioned in Git and letting a tool make reality match the description. It is not "automating creation": it is that the file is the source of truth. Four properties follow from that:
- Reproducibility. The same file produces the same result in another region, in another account or six months from now.
- Review. Infrastructure goes through a pull request just like the code in 08-01: a change to a security group gets commented on, approved and left with an author, a date and a reason.
- Controlled drift and living documentation. The tool knows what should exist, so it can compare that with what does exist — without IaC, drift is invisible by definition — and the file cannot fall out of date with reality, because it is what creates it.
- It can be deleted without fear. When bringing an environment up costs one command, switching it off on Friday stops being a risk and becomes a saving. It is the property that convinces the sceptics.
MercadoFresco has used the imperative approach so far: commands that describe how to reach the state. CloudFormation is declarative: you describe what you want and the service works out the steps.
# This is how the VPC was created in module 3. Run this block twice and you will have TWO VPCs.
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --profile mercadofresco-dev
aws ec2 create-subnet --vpc-id vpc-0a1b2c3d --cidr-block 10.0.1.0/24 --availability-zone eu-west-1a
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --vpc-id vpc-0a1b2c3d --internet-gateway-id igw-04e5f6a7The script knows nothing about the previous state: every command creates. Making it idempotent would mean wrapping every line in a check, handling the generated identifiers and deciding what to do if the resource already exists with a different configuration; in other words, reimplementing CloudFormation in Bash.
| Aspect | CLI scripts (imperative) | CloudFormation (declarative) |
|---|---|---|
| What you describe | The steps to reach the state | The desired state |
| Running it twice | Duplicates resources or fails | Does nothing: it already matches |
| Updating / creation order | Another script; manual waits | You edit the file; the order is deduced from the dependencies |
| Failing halfway | Manual undo, sometimes impossible | Automatic stack rollback |
| Deleting everything / inventory | A reverse script by hand / a manual list | delete-stack / the stack is the inventory |
| Manual changes | Undetectable | Drift detection |
The imperative does not go away: it is still the right thing for one-off operations (aws s3 cp, restarting an instance, querying metrics). The rule: if the resource has to still be there tomorrow, it goes in a template; if it is an action that happens once, it goes in a command.
Anatomy of a template
A YAML or JSON document with nine possible sections. Only Resources is mandatory.
AWSTemplateFormatVersion: '2010-09-09' # Format version. The only one that exists.
Description: MercadoFresco base network. # Documentation. Always put it in.
Metadata: { 'AWS::CloudFormation::Interface': {} } # For tools and the console. Deploys nothing.
Parameters: # Inputs: they make the template reusable across environments.
Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
Mappings: { PorEntorno: { produccion: { NatPorAz: 2 } } } # Static lookup tables
Conditions: # Booleans built from parameters; they enable resources.
EsProduccion: !Equals [ !Ref Entorno, produccion ]
Transform: AWS::Serverless-2016-10-31 # Macros. SAM is the best known. Optional.
Resources: # THE ONLY MANDATORY SECTION.
Vpc: { Type: 'AWS::EC2::VPC', Properties: { CidrBlock: 10.0.0.0/16 } }
Outputs: # Values the stack publishes, to read or to import.
IdVpc: { Value: !Ref Vpc }Missing are Rules (validation of parameter combinations) and Hooks, both far less common. The structure of a resource is always the same, with Type and Properties mandatory and the rest optional:
NombreLogico: # Identifier in the template. It is NOT the resource name.
Type: AWS::EC2::Subnet # AWS::<service>::<resource>
Condition: EsProduccion # Optional: condition, explicit dependency and deletion and
DependsOn: [ AdjuntarIgw ] # replacement policies, all explained further down
DeletionPolicy: Retain
Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.1.0/24 }First important rule: changing the logical name of a resource is the same as deleting it and creating another one. CloudFormation identifies resources by their logical name, not by what they do. Renaming Vpc to VpcPrincipal in a deployed template destroys the VPC.
Parameters: types, constraints and values from SSM
Without parameters you would need three almost identical templates for the three environments.
Parameters:
Entorno: # No Default on purpose: forces a decision every time.
Type: String
AllowedValues: [ desarrollo, preproduccion, produccion ]
CidrVpc:
Type: String
Default: 10.0.0.0/16
AllowedPattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'
ConstraintDescription: Must be a valid CIDR, for example 10.0.0.0/16.
CapacidadMinima: { Type: Number, Default: 2, MinValue: 1, MaxValue: 10 }
Propietario: { Type: String, Default: marta, MinLength: 2, MaxLength: 32 }
ClaveSsh: { Type: 'AWS::EC2::KeyPair::KeyName' } # Validates it exists BEFORE creating anything
SubredesApp: { Type: 'List<AWS::EC2::Subnet::Id>' } # List of existing subnets
Contrasena: { Type: String, NoEcho: true } # Hidden in console and events. Does NOT encrypt.
AmiTienda: # The value is NOT the path: CloudFormation resolves the
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id> # SSM parameter and uses its contents
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64Three things separate an amateur template from a professional one:
- The AWS-specific types validate before anything starts. With
Type: Stringfor a non-existent SSH key, the stack creates half a dozen resources and fails when it reaches the instance. WithAWS::EC2::KeyPair::KeyNameit fails at second zero, without creating anything. AWS::SSM::Parameter::Value<...>decouples the template from the values. The shop's AMI changes every month; the template should not have to change because of that. MercadoFresco uses it with/mercadofresco/produccion/ami-tienda.NoEchodoes not encrypt, it only hides. Passwords do not belong in parameters: they belong in Secrets Manager (04-03) and are referenced with dynamic resolution, which is evaluated at deployment time without the value ever entering the template:
# The same syntax works with ssm and ssm-secure for Parameter Store
MasterUserPassword: '{{resolve:secretsmanager:mercadofresco/produccion/rds/mfadmin:SecretString:password}}'Mappings, Conditions and intrinsic functions
Mappings is a static two-level lookup table, useful for varying by region or by environment; it is read with Fn::FindInMap. Conditions computes booleans with Fn::Equals, Fn::And, Fn::Or and Fn::Not.
Mappings:
PorEntorno:
desarrollo: { NatPorAz: 1, Tipo: t3.small, RetencionRegistros: 7 }
preproduccion: { NatPorAz: 1, Tipo: t3.medium, RetencionRegistros: 30 }
produccion: { NatPorAz: 2, Tipo: m6i.large, RetencionRegistros: 365 }
Conditions:
EsProduccion: !Equals [ !Ref Entorno, produccion ]
EsPreproduccion: !Equals [ !Ref Entorno, preproduccion ]
NecesitaAltaDisponibilidad: !Or [ !Condition EsProduccion, !Condition EsPreproduccion ]
CrearSegundoNat: !Equals [ !FindInMap [ PorEntorno, !Ref Entorno, NatPorAz ], 2 ]They are read with !FindInMap [ PorEntorno, !Ref Entorno, Tipo ]. A resource with Condition: CrearSegundoNat is only created if the condition is true: nat-mercadofresco-b exists in production and not in development with the same template, and that saves around 32 USD a month per non-production environment. A word of warning: use them sparingly. A template with twelve nested conditions is unreadable and impossible to reason about when it fails; if the difference between environments is large, what you need is different templates, not more conditions.
Intrinsic functions
Each one has a long form (Fn::Sub) and a short form (!Sub). In YAML the short one is used except when two short forms have to be nested one straight after the other, which YAML does not allow.
| Function | What it returns | Example in MercadoFresco |
|---|---|---|
!Ref |
The resource's "natural" identifier or the parameter's value | !Ref Vpc → vpc-0a1b2c3d |
!GetAtt |
An attribute of the resource | !GetAtt Alb.DNSName |
!Sub |
A string with variable substitution | !Sub 'mercadofresco-${Entorno}-registros' |
!Join / !Select / !Split |
Joins, extracts element N and splits lists | !Select [ 0, !GetAZs '' ] |
!ImportValue |
A value exported by another stack | !ImportValue mercadofresco-red-IdVpc |
!FindInMap / !If |
A value from a Mappings / a conditional value |
!If [ EsProduccion, m6i.large, t3.small ] |
!GetAZs / !Cidr |
The region's AZs / calculated subnets | !Cidr [ 10.0.0.0/16, 6, 8 ] |
What causes the most confusion is !Ref, because it returns different things depending on the type: on a VPC or a subnet, its identifier; on a bucket, the name; on an SQS queue, the URL; on an SNS topic, the ARN; on an IAM role, the name; on a Lambda, the name. That table explains 80 % of the Value of property X must be of type String errors. When you need an ARN it is almost always !GetAtt Recurso.Arn; the "Return values" section of each type has the exact list.
# Substitution of parameters and pseudo-parameters
BucketName: !Sub 'mercadofresco-${Entorno}-registros-${AWS::AccountId}'
# Attributes of other resources: the dot syntax works too
Value: !Sub 'https://${Distribucion.DomainName}/catalogo'
# With a map of local variables, when another function has to be nested
Description: !Sub
- 'Orders queue for the ${Ent} environment in account ${Cuenta}'
- { Ent: !Ref Entorno, Cuenta: !Ref 'AWS::AccountId' }
# In development, no final snapshot; in production, with one: NoValue omits the property
FinalSnapshotIdentifier: !If [ EsProduccion, !Sub 'final-${AWS::StackName}', !Ref 'AWS::NoValue' ]The pseudo-parameters are variables AWS always provides: AWS::AccountId (111122223333), AWS::Region (eu-west-1), AWS::StackName, AWS::StackId, AWS::Partition, AWS::URLSuffix and AWS::NoValue.
Stacks: lifecycle, states and ROLLBACK_COMPLETE
A stack is a deployed template: resources that are created, updated and deleted as a unit. It is the unit of management, of permissions and of drift.
stateDiagram-v2
[*] --> CREATE_IN_PROGRESS: create-stack
CREATE_IN_PROGRESS --> CREATE_COMPLETE: all good
CREATE_IN_PROGRESS --> ROLLBACK_IN_PROGRESS: failure during creation
ROLLBACK_IN_PROGRESS --> ROLLBACK_COMPLETE: resources undone
ROLLBACK_COMPLETE --> [*]: can only be DELETED
CREATE_COMPLETE --> UPDATE_IN_PROGRESS: update-stack
UPDATE_IN_PROGRESS --> UPDATE_COMPLETE: all good
UPDATE_IN_PROGRESS --> UPDATE_ROLLBACK_COMPLETE: failure, goes back and can be retried
CREATE_COMPLETE --> DELETE_IN_PROGRESS: delete-stack
DELETE_IN_PROGRESS --> DELETE_FAILED: a resource refuses to be deleted
The state that baffles everyone on day one is ROLLBACK_COMPLETE: it happens when the initial creation fails and CloudFormation undoes what it created. The stack carries on existing but in a terminal state: it cannot be updated, only deleted and created again with the corrected template. Not to be confused with UPDATE_ROLLBACK_COMPLETE, which is healthy: the stack went back to its previous version and can carry on being updated.
aws cloudformation validate-template --template-body file://red-mercadofresco.yaml # syntax only
aws cloudformation create-stack --stack-name mercadofresco-red-produccion \
--template-body file://red-mercadofresco.yaml \
--parameters ParameterKey=Entorno,ParameterValue=produccion \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion Key=Componente,Value=red \
--enable-termination-protection --profile mercadofresco-dev --region eu-west-1
aws cloudformation wait stack-create-complete --stack-name mercadofresco-red-produccion
# The FIRST CREATE_FAILED is the real cause; the rest are consequences
aws cloudformation describe-stack-events --stack-name mercadofresco-red-produccion \
--query 'StackEvents[?ResourceStatus==`CREATE_FAILED`].[LogicalResourceId,ResourceStatusReason]' \
--output tableStack tags propagate to every resource that supports them: it is the cheapest way to comply with MercadoFresco's mandatory tagging without repeating it resource by resource (picked up again in 11-02). For day-to-day work, deploy creates the stack if it does not exist and updates it if it does, with a change set underneath:
aws cloudformation deploy --stack-name mercadofresco-red-produccion \
--template-file red-mercadofresco.yaml --parameter-overrides Entorno=produccion \
--no-fail-on-empty-changeset --capabilities CAPABILITY_NAMED_IAM--no-fail-on-empty-changeset stops the pipeline going red when there is nothing to change, which is the most frequent case. --capabilities is mandatory when the template creates IAM resources: it is an explicit acknowledgement that you know you are creating permissions (CAPABILITY_IAM, CAPABILITY_NAMED_IAM if you name them, CAPABILITY_AUTO_EXPAND for macros and nested stacks).
Deletion policies and termination protection
By default, deleting a stack deletes its resources. For aurora-mercadofresco-pedidos and mercadofresco-catalogo-fotos that is unacceptable.
BaseDatosPedidos:
Type: AWS::RDS::DBCluster
DeletionPolicy: Snapshot # On stack deletion: final snapshot and then delete
UpdateReplacePolicy: Retain # If an update requires replacement: keep the old one
BucketFotos:
Type: AWS::S3::Bucket
DeletionPolicy: Retain # Leaves it standing, orphaned from the stack
UpdateReplacePolicy: Retain
Properties: { BucketName: mercadofresco-catalogo-fotos }| Policy | Values | What it does |
|---|---|---|
DeletionPolicy |
Delete (default) |
Deletes the resource when the stack is deleted |
Retain / RetainExceptOnCreate |
Leaves it in place / the same, but does delete on a creation rollback | |
Snapshot |
Final snapshot and deletion (RDS, Redshift, ElastiCache, EBS, Neptune) | |
UpdateReplacePolicy |
The same values | Applies when an update replaces the resource |
UpdateReplacePolicy is the one people forget and the one that prevents the most grief: changing Aurora's DBClusterIdentifier causes a replacement, and without it the old cluster — with the data — disappears as soon as the new one is ready. Termination protection is one more layer, at stack level: aws cloudformation update-termination-protection --stack-name mercadofresco-datos-produccion --enable-termination-protection, and with it active delete-stack simply fails. MercadoFresco's rule: termination protection on the three production stacks, and Retain or Snapshot on every stateful resource, in every environment.
Change sets and the three types of update
A change set is a simulation: CloudFormation compares the new template with the current stack and says what it would do, without doing it. It is the answer to the question nobody could answer in module 8: what is going to happen if I apply this?
aws cloudformation create-change-set --stack-name mercadofresco-red-produccion \
--change-set-name anadir-endpoint-dynamodb --template-body file://red-mercadofresco.yaml \
--parameters ParameterKey=Entorno,ParameterValue=produccion --capabilities CAPABILITY_IAM
aws cloudformation describe-change-set --stack-name mercadofresco-red-produccion \
--change-set-name anadir-endpoint-dynamodb \
--query 'Changes[].ResourceChange.[Action,LogicalResourceId,ResourceType,Replacement]' --output table
# | Add | EndpointDynamoDb | AWS::EC2::VPCEndpoint | None |
# | Modify | TablaRutasApp | AWS::EC2::RouteTable | False |
# | Modify | SubredDatosA | AWS::EC2::Subnet | True |The last line is a red alert: Replacement: True means the subnet is deleted and another one created, with a new identifier, and everything that depends on it is affected. Without looking at the change set, that would have happened on a Friday at eleven.
| Type | What happens | Physical identifier | Examples |
|---|---|---|---|
| No interruption | Updated in place | Kept | Tags, SG rules, ASG size, DynamoDB capacity |
| With interruption | Stopped and started | Kept | EC2 InstanceType, RDS class without Multi-AZ, Lambda memory |
| With replacement | A new one is created and the old one deleted | Changes | Subnet AvailabilityZone or CidrBlock, BucketName, DBClusterIdentifier, KeyName |
The third row is the one that hurts: a replacement on mercadofresco-catalogo-fotos means a new empty bucket and the old one deleted with the 40,000 photos inside, unless UpdateReplacePolicy prevents it. The documentation for each type states, property by property, the "Update requires", and it has to be checked before writing the change, not afterwards.
flowchart LR
A[Edit template] --> B[create-change-set]
B --> C{describe-change-set}
C -->|Replacement False| D[execute-change-set]
C -->|Any Replacement True| E{Is it acceptable?}
E -->|No| F[Another approach or<br/>migrate the data first]
E -->|Yes, with a window| D
D --> G[UPDATE_COMPLETE or<br/>UPDATE_ROLLBACK_COMPLETE]
A word of caution: if somebody modifies the stack between your creating it and executing it, the simulation is no longer valid; that is why pipelines create it and execute it in consecutive stages, not hours apart.
Drift detection
Drift is the difference between what the template says and what is actually there. It always appears: during an incident, at three in the morning, nobody edits a template.
ID=$(aws cloudformation detect-stack-drift --stack-name mercadofresco-red-produccion \
--query StackDriftDetectionId --output text) # detection is asynchronous
aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id "$ID"
aws cloudformation describe-stack-resource-drifts --stack-name mercadofresco-red-produccion \
--stack-resource-drift-status-filters MODIFIED DELETEDThe per-resource states are IN_SYNC, MODIFIED, DELETED and NOT_CHECKED. That last one matters: not every type supports drift detection, so an IN_SYNC at stack level does not guarantee nothing has changed. When drift is found there are two paths: update the template to reflect reality, if the manual change was legitimate, documenting the reason in the PR; or reapply the stack to undo it. An important nuance: CloudFormation has no automatic "fix drift", because an update only touches what changed in the template; to force it you have to alter the drifted value in the template and update. At MercadoFresco a scheduled job runs it every week over the production stacks and publishes the result to alertas-mercadofresco. AWS Config (05-04) also has the cloudformation-stack-drift-detection-check rule, which does this continuously.
MercadoFresco's network in a template
red-mercadofresco.yaml, in the mercadofresco-infra repository, reproduces the network from module 3.
AWSTemplateFormatVersion: '2010-09-09'
Description: MercadoFresco - Base network. VPC with six subnets across two AZs, IGW, NAT and S3 endpoint.
Parameters:
Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
CidrVpc: { Type: String, Default: 10.0.0.0/16 }
Propietario: { Type: String, Default: marta }
Mappings:
PorEntorno: { desarrollo: { NatPorAz: 1 }, preproduccion: { NatPorAz: 1 }, produccion: { NatPorAz: 2 } }
Conditions:
# Only production has two NATs; everywhere else, app-b goes out through the AZ a NAT.
CrearSegundoNat: !Equals [ !FindInMap [ PorEntorno, !Ref Entorno, NatPorAz ], 2 ]
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref CidrVpc
EnableDnsSupport: true # Needed for the S3 endpoint and the RDS names
EnableDnsHostnames: true
Tags: [ { Key: Name, Value: vpc-mercadofresco }, { Key: Proyecto, Value: mercadofresco },
{ Key: Entorno, Value: !Ref Entorno }, { Key: Componente, Value: red },
{ Key: Propietario, Value: !Ref Propietario }, { Key: CentroCoste, Value: plataforma } ]
Igw: { Type: 'AWS::EC2::InternetGateway',
Properties: { Tags: [ { Key: Name, Value: igw-mercadofresco } ] } }
AdjuntarIgw: { Type: 'AWS::EC2::VPCGatewayAttachment',
Properties: { VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw } }
# --- Subnets. !Select with !GetAZs avoids pinning 'eu-west-1a': the template works in another region.
SubredPublicaA:
Type: AWS::EC2::Subnet
Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.0.0/24, MapPublicIpOnLaunch: true,
AvailabilityZone: !Select [ 0, !GetAZs '' ],
Tags: [ { Key: Name, Value: snet-mercadofresco-publica-a } ] }
SubredPublicaB:
Type: AWS::EC2::Subnet
Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.1.0/24, MapPublicIpOnLaunch: true,
AvailabilityZone: !Select [ 1, !GetAZs '' ],
Tags: [ { Key: Name, Value: snet-mercadofresco-publica-b } ] }
SubredAppA: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.10.0/24,
AvailabilityZone: !Select [ 0, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-app-a } ] } }
SubredAppB: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.11.0/24,
AvailabilityZone: !Select [ 1, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-app-b } ] } }
SubredDatosA: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.20.0/24,
AvailabilityZone: !Select [ 0, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-datos-a } ] } }
SubredDatosB: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.21.0/24,
AvailabilityZone: !Select [ 1, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-datos-b } ] } }
# --- NAT: the second one only exists in production
IpNatA: { Type: 'AWS::EC2::EIP', Properties: { Domain: vpc } }
IpNatB: { Type: 'AWS::EC2::EIP', Condition: CrearSegundoNat, Properties: { Domain: vpc } }
NatA: { Type: 'AWS::EC2::NatGateway', Properties: { AllocationId: !GetAtt IpNatA.AllocationId,
SubnetId: !Ref SubredPublicaA, Tags: [ { Key: Name, Value: nat-mercadofresco-a } ] } }
NatB: { Type: 'AWS::EC2::NatGateway', Condition: CrearSegundoNat,
Properties: { AllocationId: !GetAtt IpNatB.AllocationId, SubnetId: !Ref SubredPublicaB,
Tags: [ { Key: Name, Value: nat-mercadofresco-b } ] } }
# --- Route tables
TablaRutasPublica: { Type: 'AWS::EC2::RouteTable',
Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-publica } ] } }
RutaInternet:
Type: AWS::EC2::Route
DependsOn: AdjuntarIgw # EXPLICIT dependency: it cannot be deduced from the properties
Properties: { RouteTableId: !Ref TablaRutasPublica, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref Igw }
AsociarPublicaA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredPublicaA, RouteTableId: !Ref TablaRutasPublica } }
AsociarPublicaB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredPublicaB, RouteTableId: !Ref TablaRutasPublica } }
TablaRutasAppA: { Type: 'AWS::EC2::RouteTable',
Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-app-a } ] } }
RutaNatA: { Type: 'AWS::EC2::Route', Properties: { RouteTableId: !Ref TablaRutasAppA,
DestinationCidrBlock: 0.0.0.0/0, NatGatewayId: !Ref NatA } }
AsociarAppA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredAppA, RouteTableId: !Ref TablaRutasAppA } }
TablaRutasAppB: { Type: 'AWS::EC2::RouteTable',
Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-app-b } ] } }
RutaNatB: # With a second NAT it goes out through its own; otherwise it shares the AZ a one
Type: AWS::EC2::Route
Properties: { RouteTableId: !Ref TablaRutasAppB, DestinationCidrBlock: 0.0.0.0/0,
NatGatewayId: !If [ CrearSegundoNat, !Ref NatB, !Ref NatA ] }
AsociarAppB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredAppB, RouteTableId: !Ref TablaRutasAppB } }
# The data subnets have NO route to 0.0.0.0/0: no way out to the internet, by design (03-01).
TablaRutasDatos: { Type: 'AWS::EC2::RouteTable',
Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-datos } ] } }
AsociarDatosA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredDatosA, RouteTableId: !Ref TablaRutasDatos } }
AsociarDatosB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
Properties: { SubnetId: !Ref SubredDatosB, RouteTableId: !Ref TablaRutasDatos } }
EndpointS3: # Gateway type: costs nothing and saves the NAT traffic of the backups to S3
Type: AWS::EC2::VPCEndpoint
Properties: { VpcId: !Ref Vpc, VpcEndpointType: Gateway,
ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3',
RouteTableIds: [ !Ref TablaRutasAppA, !Ref TablaRutasAppB, !Ref TablaRutasDatos ] }
Outputs:
IdVpc: { Value: !Ref Vpc, Export: { Name: !Sub '${AWS::StackName}-IdVpc' } }
SubredesPublicas: { Value: !Join [ ',', [ !Ref SubredPublicaA, !Ref SubredPublicaB ] ],
Export: { Name: !Sub '${AWS::StackName}-SubredesPublicas' } }
SubredesApp: { Value: !Join [ ',', [ !Ref SubredAppA, !Ref SubredAppB ] ],
Export: { Name: !Sub '${AWS::StackName}-SubredesApp' } }
SubredesDatos: { Value: !Join [ ',', [ !Ref SubredDatosA, !Ref SubredDatosB ] ],
Export: { Name: !Sub '${AWS::StackName}-SubredesDatos' } }Four comments. The creation order is nowhere to be found: CloudFormation works out that SubredPublicaA needs Vpc because it references it with !Ref, and creates independent things in parallel; the only DependsOn is the only dependency that cannot be deduced from the properties. !GetAZs avoids pinning eu-west-1a. !If with !Ref NatB degrades gracefully in small environments without duplicating the template. And the outputs are exported with the ${AWS::StackName} prefix, so that production and pre-production do not clash: export names are unique per region.
cfn-lint red-mercadofresco.yaml
aws cloudformation deploy --stack-name mercadofresco-red-produccion \
--template-file red-mercadofresco.yaml --parameter-overrides Entorno=produccion Propietario=marta \
--tags Proyecto=mercadofresco Entorno=produccion Componente=red Propietario=marta \
CentroCoste=plataforma --profile mercadofresco-dev --region eu-west-1Seven minutes later — the NATs take about two minutes each — the complete network exists. And this time it exists in a file as well, with a history in Git.
The application layer that imports the outputs
aplicacion-mercadofresco.yaml builds the ALB, the target group and the ASG on top, without knowing a single identifier:
Parameters:
Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
PilaRed: { Type: String, Default: mercadofresco-red-produccion }
AmiTienda: { Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>',
Default: /mercadofresco/produccion/ami-tienda }
Mappings:
PorEntorno: { desarrollo: { Tipo: t3.small, Min: 1, Max: 2 },
preproduccion: { Tipo: t3.medium, Min: 2, Max: 3 }, produccion: { Tipo: m6i.large, Min: 2, Max: 4 } }
Resources:
SgAlb: { Type: 'AWS::EC2::SecurityGroup', Properties: {
GroupDescription: Public HTTPS ingress to the ALB,
VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
SecurityGroupIngress: [ { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 } ] } }
SgTienda: { Type: 'AWS::EC2::SecurityGroup', Properties: { # Source by security group and
GroupDescription: Traffic from the ALB to the shop, # not by CIDR: the 03-02 pattern
VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
SecurityGroupIngress: [ { IpProtocol: tcp, FromPort: 8080, ToPort: 8080,
SourceSecurityGroupId: !Ref SgAlb } ] } }
Alb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties: { Name: !Sub 'alb-mercadofresco-tienda-${Entorno}', Scheme: internet-facing,
Type: application, SecurityGroups: [ !Ref SgAlb ],
Subnets: !Split [ ',', !ImportValue { 'Fn::Sub': '${PilaRed}-SubredesPublicas' } ] }
GrupoDestino:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties: { Name: !Sub 'tg-mercadofresco-tienda-${Entorno}', Port: 8080, Protocol: HTTP,
VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
HealthCheckPath: /salud, HealthCheckIntervalSeconds: 15,
HealthyThresholdCount: 2, UnhealthyThresholdCount: 3,
TargetGroupAttributes: [ { Key: deregistration_delay.timeout_seconds, Value: '30' } ] }
PlantillaLanzamiento:
Type: AWS::EC2::LaunchTemplate
Properties: { LaunchTemplateName: !Sub 'lt-mercadofresco-tienda-${Entorno}',
LaunchTemplateData: { ImageId: !Ref AmiTienda, SecurityGroupIds: [ !Ref SgTienda ],
InstanceType: !FindInMap [ PorEntorno, !Ref Entorno, Tipo ],
IamInstanceProfile: { Name: rol-mercadofresco-tienda },
MetadataOptions: { HttpTokens: required } } } # IMDSv2 mandatory (04-01)
Asg:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
AutoScalingGroupName: !Sub 'asg-mercadofresco-tienda-${Entorno}'
MinSize: !FindInMap [ PorEntorno, !Ref Entorno, Min ]
MaxSize: !FindInMap [ PorEntorno, !Ref Entorno, Max ]
HealthCheckType: ELB
HealthCheckGracePeriod: 120
VPCZoneIdentifier: !Split [ ',', !ImportValue { 'Fn::Sub': '${PilaRed}-SubredesApp' } ]
TargetGroupARNs: [ !Ref GrupoDestino ]
LaunchTemplate: { LaunchTemplateId: !Ref PlantillaLanzamiento,
Version: !GetAtt PlantillaLanzamiento.LatestVersionNumber }
UpdatePolicy: # How to replace the instances when the launch template changes
AutoScalingRollingUpdate: { MinInstancesInService: 2, MaxBatchSize: 1, PauseTime: PT5M }
Outputs:
DnsAlb: { Value: !GetAtt Alb.DNSName }Two details. YAML does not allow two short tags in a row, so !ImportValue with !Sub inside forces the inner one to be written in long form ('Fn::Sub'); the error you get for forgetting, unsupported structure, is no help at all. And UpdatePolicy decides what happens when the launch template changes: without it, CloudFormation updates the ASG and does not touch the existing instances, so the change is not applied until the next scaling event. It is the 08-03 rolling deployment applied to infrastructure.
Several stacks: exports versus SSM
A stack with 300 resources takes 40 minutes, rolls back in its entirety on any failure and cannot be touched by one person without coordinating with the other two. The split follows two criteria: by lifecycle (what changes together goes together) and by ownership (each stack has a clear owner).
| Stack | Contents | Frequency | Owner |
|---|---|---|---|
mercadofresco-red-<entorno> |
VPC, subnets, NAT, routes, endpoints | Very low | Marta |
mercadofresco-datos-<entorno> |
Aurora, DynamoDB, ElastiCache, buckets | Low | Marta |
mercadofresco-integracion-<entorno> |
Queues, DLQs, topics, bus, rules, Step Functions | Medium | Luis |
mercadofresco-aplicacion-<entorno> |
ALB, target groups, ASG, Lambdas | High | Luis |
mercadofresco-observabilidad-<entorno> |
Alarms, dashboards, metric filters | Medium | Marta |
| Criterion | Exports and Fn::ImportValue |
SSM parameters |
|---|---|---|
| Coupling | Strong: an export in use cannot be changed or deleted | Weak: it is only a value being read |
| Scope | Same account and same region | Anywhere: it crosses accounts and regions |
| Updating the value / deletion order | Impossible while it is imported; order enforced | Free; the order is left to discipline |
| Readability | High: the dependency is explicit | Lower: you have to know which path each stack reads |
| Typical risk | Ending up unable to change the network | Somebody changes the value and nobody notices |
The first row is the key: an export in use is a lock. That is good — it stops things breaking — and it is bad — it blocks evolution. MercadoFresco's rule: exports for the structural things that are not going to change (VPC and subnets), and SSM parameters for everything else and whenever the value crosses accounts or regions (queue ARNs, table names, key aliases).
ParametroArnColaPedidos: # Published from the integration stack
Type: AWS::SSM::Parameter
Properties: { Type: String, Value: !GetAtt ColaPedidos.Arn,
Name: !Sub '/mercadofresco/${Entorno}/integracion/arn-cola-pedidos' }
# And the application stack consumes it as a parameter:
# ArnColaPedidos: { Type: 'AWS::SSM::Parameter::Value<String>',
# Default: /mercadofresco/produccion/integracion/arn-cola-pedidos }Nested stacks, modules and custom resources
Nested stacks include one template inside another as if it were a resource; the child must live in S3 (mercadofresco-artefactos will do) and its outputs are read with !GetAtt PilaSubredes.Outputs.SubredesApp.
PilaSubredes:
Type: AWS::CloudFormation::Stack
Properties: { Parameters: { IdVpc: !Ref Vpc, Entorno: !Ref Entorno },
TemplateURL: https://s3.eu-west-1.amazonaws.com/mercadofresco-artefactos/plantillas/subredes.yaml }| Approach | When | Drawbacks |
|---|---|---|
| Separate stacks | Different lifecycles and owners | The order has to be coordinated |
| Nested stacks | One single lifecycle with repetitive parts | Hard to debug; upload to S3; CAPABILITY_AUTO_EXPAND |
| Modules | An identical pattern across many templates | Have to be registered per account and region; not widely used |
Sooner or later something turns up that CloudFormation does not know how to create: a user in an external tool, an initial data load. A custom resource delegates to a Lambda: CloudFormation sends it an event with a RequestType and a presigned URL, and the function must respond to that URL.
import json, urllib.request
def respond(event, context, status, data=None, reason=""):
"""Respond to the presigned URL. If it is not called, the stack hangs for an hour."""
body = json.dumps({"Status": status, # SUCCESS or FAILED
"Reason": reason or f"See logs: {context.log_stream_name}",
"PhysicalResourceId": event.get("PhysicalResourceId", context.log_stream_name),
"StackId": event["StackId"], "RequestId": event["RequestId"],
"LogicalResourceId": event["LogicalResourceId"], "Data": data or {}}).encode()
request = urllib.request.Request(event["ResponseURL"], data=body, method="PUT")
request.add_header("content-type", "")
urllib.request.urlopen(request)
def handler(event, context):
try:
if event["RequestType"] == "Delete":
return respond(event, context, "SUCCESS") # Nothing to undo
load_categories(event["ResourceProperties"]["Tabla"])
respond(event, context, "SUCCESS", {"Categorias": "12"})
except Exception as error: # ALWAYS catch: an exception without
respond(event, context, "FAILED", reason=str(error)) # responding hangs the stack for 1 hourResource providers are the formal alternative: you register a new type, for example MercadoFresco::Facturacion::Cliente, with its JSON schema and its handlers, and from then on it is used like any other resource, drift and validation included. It costs more and you pay per handler second, but the public registry already carries third-party types — Datadog, MongoDB Atlas, GitHub — that are activated in one click.
Importing existing resources into a stack
This is the operation MercadoFresco really needs, because its infrastructure already exists. The naive alternative — creating new stacks and deleting the old ones — would mean migrating data and a cutover window. Importing adopts the existing resources without touching them. Four steps:
- Write the template that describes exactly what exists. There is no improvising here: if it says
/healthand the real target group has/salud, the import goes through and the nextupdate-stackchanges reality without warning. - Add
DeletionPolicy: Retainto every resource being imported — it is mandatory — and prepare the identifiers file, which pairs each logical name with the real resource. - Create and execute a change set of type
IMPORT.
[
{ "ResourceType": "AWS::EC2::VPC", "LogicalResourceId": "Vpc",
"ResourceIdentifier": { "VpcId": "vpc-0a1b2c3d4e5f6a7b8" } },
{ "ResourceType": "AWS::EC2::Subnet", "LogicalResourceId": "SubredAppA",
"ResourceIdentifier": { "SubnetId": "subnet-0c1d2e3f4a5b6c7d8" } },
{ "ResourceType": "AWS::SQS::Queue", "LogicalResourceId": "ColaPedidos", "ResourceIdentifier": {
"QueueUrl": "https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-pedidos" } }
]aws cloudformation create-change-set --stack-name mercadofresco-red-produccion \
--change-set-name importar-red-existente --change-set-type IMPORT \
--resources-to-import file://recursos-a-importar.json \
--template-body file://red-mercadofresco.yaml \
--parameters ParameterKey=Entorno,ParameterValue=produccion
aws cloudformation execute-change-set --stack-name mercadofresco-red-produccion \
--change-set-name importar-red-existente
aws cloudformation detect-stack-drift --stack-name mercadofresco-red-produccion # MANDATORY STEPImporting does not check that the template matches reality: only that the resources exist and are of the declared type. Drift detection immediately afterwards is what reveals the differences. Marta finds three:
| Resource | Drift | Decision |
|---|---|---|
SubredAppB |
Propietario tag missing |
Fix reality: apply the template |
TablaRutasDatos |
A 0.0.0.0/0 route to the NAT that nobody remembered |
Investigate and remove: the data does not go out |
EndpointS3 |
Policy more restrictive than the template | Fix the template: reality was the right one |
The third case explains why the order matters: import and detect first, correct afterwards. Writing the template "as it ought to be" and applying it directly would have relaxed a security policy without anybody noticing. MercadoFresco imports layer by layer, starting with the network — the most stable one — and leaving the data layer until last, with an agreed window and a recent Aurora backup.
StackSets and deployment from the pipeline
A StackSet deploys the same template across a set of accounts and regions from a single operation. Today MercadoFresco has one account, so its usefulness is limited; in 09-04, with four, it will be the piece that guarantees every new account is born with the baseline in place: trail, Config recorder, billing alarms and roles.
aws cloudformation create-stack-set --stack-set-name ss-mercadofresco-linea-base \
--template-body file://linea-base.yaml --permission-model SERVICE_MANAGED \
--auto-deployment Enabled=true --capabilities CAPABILITY_NAMED_IAMSELF_MANAGED requires creating two IAM roles by hand; SERVICE_MANAGED leans on Organizations and requires nothing. With --auto-deployment Enabled=true, an account added to a target OU gets the stack on its own. And the practical conclusion from module 8: if the infrastructure is code, it gets deployed from the pipeline. CodePipeline has a native action with four modes; MercadoFresco's pattern separates simulation from execution with an approval in between.
flowchart TB
A[Source: mercadofresco-infra] --> B[Build:<br/>cfn-lint + cfn-guard]
B --> C[CHANGE_SET_REPLACE]
C --> D[Publish the change set<br/>summary]
D --> E{Marta approves}
E -->|Rejects| G[End: nothing has been touched]
E -->|Approves| F[CHANGE_SET_EXECUTE] --> H[Check drift and outputs]
- name: InfraestructuraProduccion
actions:
- name: PrepararCambio
actionTypeId: { category: Deploy, owner: AWS, provider: CloudFormation, version: '1' }
runOrder: 1
configuration:
ActionMode: CHANGE_SET_REPLACE
StackName: mercadofresco-red-produccion
ChangeSetName: cambio-desde-pipeline
TemplatePath: 'Infra::plantillas/red-mercadofresco.yaml'
TemplateConfiguration: 'Infra::parametros/produccion.json'
RoleArn: arn:aws:iam::111122223333:role/rol-cloudformation-mercadofresco
Capabilities: CAPABILITY_NAMED_IAM
- { name: AprobacionMarta, runOrder: 2,
actionTypeId: { category: Approval, owner: AWS, provider: Manual, version: '1' } }
- name: EjecutarCambio
actionTypeId: { category: Deploy, owner: AWS, provider: CloudFormation, version: '1' }
runOrder: 3
configuration: { ActionMode: CHANGE_SET_EXECUTE, StackName: mercadofresco-red-produccion,
ChangeSetName: cambio-desde-pipeline }RoleArn is key: CloudFormation assumes that role to create the resources, not the permissions of whoever launched the pipeline, so Luis can trigger an infrastructure deployment without having permission to create VPCs himself. And TemplateConfiguration points at a per-environment JSON in the repository with Parameters and Tags: with that, the difference between pre-production and production stops being a mystery and becomes a file you compare with diff in ten seconds.
Best practices, cfn-lint and cfn-guard
Six rules in the README of mercadofresco-infra:
- Small templates with a single responsibility. More than 400 lines or 80 resources: split it, by lifecycle.
- Minimal parameters. Every parameter is a decision somebody can get wrong. What does not vary between environments is a constant, not a parameter.
- No magic values, and physical names only when they are needed. No hand-written AMIs, no repeated CIDRs, no raw ARNs:
!Sub,Mappingsand SSM cover every case. And pinningBucketNameorGroupNameprevents no-interruption updates and blocks deploying the same template twice in one account. - Everything goes through a PR and through automatic validation. No manual
deployagainst production; an emergency means opening the reconciliation PR the same day.
pip install cfn-lint && cfn-lint plantillas/*.yaml
# E3002 Invalid Property Resources/GrupoDestino/Properties/HealthCheckPeriod
# W2001 Parameter Propietario not used
cfn-guard validate --data plantillas/ --rules reglas/mercadofresco.guardlet buckets = Resources.*[ Type == 'AWS::S3::Bucket' ]
rule buckets_encrypted when %buckets !empty {
%buckets.Properties.BucketEncryption exists
<<Every MercadoFresco bucket must declare encryption. See 04-02.>>
}
let sgs = Resources.*[ Type == 'AWS::EC2::SecurityGroup' ]
rule no_open_ssh when %sgs !empty {
%sgs.Properties.SecurityGroupIngress[*] { when FromPort == 22 { CidrIp != '0.0.0.0/0' } }
}That second rule is the one that would have prevented the pre-production versus production difference from the start of the lesson. Both tools run in build-mercadofresco-integracion (08-02) and fail the build: they are not warnings.
Limits, cost and cleanup
| Limit | Value | What to do when you reach it |
|---|---|---|
| Resources per stack / stacks per account and region | 500 / 2,000 | Split into several stacks or nest them; consolidate |
| Parameters / Outputs | 200 / 200 | Group values in SSM |
| Local template / in S3 / nesting | 51,200 bytes / 1 MB / 5 levels | --template-url; split; rethink |
CloudFormation does not charge for managing AWS resources; it charges 0.0009 USD per handler second on third-party resources and private types, with 1,000 free operations a month. The real cost is always that of the resources created.
# Delete in reverse dependency order: the consumers first
aws cloudformation delete-stack --stack-name mercadofresco-aplicacion-pruebas
aws cloudformation wait stack-delete-complete --stack-name mercadofresco-aplicacion-pruebas
aws cloudformation delete-stack --stack-name mercadofresco-red-pruebas
aws cloudformation wait stack-delete-complete --stack-name mercadofresco-red-pruebas
# Orphaned elastic IPs are charged for
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output tableIf the deletion ends in DELETE_FAILED, the cause is almost always a bucket with objects inside or a network interface created by hand in the subnet; --retain-resources lets you finish the deletion leaving those specific resources out.
Common Mistakes and Tips
Mistake: renaming a logical resource and losing the resource. Changing Vpc to VpcPrincipal does not rename anything: it deletes the VPC and creates another. Tip: logical names are immutable in practice; if you have to rename, do it in two steps with DeletionPolicy: Retain plus a later import.
Mistake: deploying without looking at the change set. It is merging a PR without reading the diff. Tip: have the pipeline generate it and publish it before the approval, paying attention to the Replacement column. Mistake: not understanding ROLLBACK_COMPLETE. It is the terminal state of a failed creation. Tip: delete it and create it again, but look at the events before deleting, filtering on CREATE_FAILED: the first is the cause, the rest are consequences.
Mistake: passwords in parameters with NoEcho, believing they are safe, or exports for everything. With twenty cross-stack exports the network becomes unmodifiable. Tip: {{resolve:secretsmanager:...}} or {{resolve:ssm-secure:...}} for the secrets, exports only for the structural, and SSM for the rest.
Mistake: believing that importing validates the template. It does not: drift detection right after every import, without exception.
Tip: use --role-arn on every deployment and a stack policy on the resources holding data. The first is the difference between "anyone who can deploy can create anything" and "only the reviewed template creates what it declares"; the second, with set-stack-policy, denies Update:Replace and Update:Delete on specific resources, a belt for the braces of UpdateReplacePolicy.
Tip: look at the deployed template, not just the one in the repository. get-template --template-stage Processed returns it with macros and transformations already applied; that is what you have to review when the result does not match what you thought you were writing.
Tip: Description in the template, in every parameter and in every output. It is the living documentation from the start of the lesson, and it costs thirty seconds.
Exercises
Exercise 1: the integration template
Write integracion-mercadofresco.yaml: the cola-mercadofresco-pedidos queue with its mercadofresco-pedidos-fallidos DLQ and 5 attempts, the mercadofresco-pedido-confirmado topic, the subscription of the queue to the topic and the publication of the queue ARN in Parameter Store. Requirements: encryption with alias/mercadofresco-datos, 14 days of retention on the DLQ and 4 on the queue, an environment suffix in the names, complete mandatory tagging and an appropriate DeletionPolicy on the DLQ. State which type of update changing VisibilityTimeout would cause, and which one changing QueueName would.
Exercise 2: adopting the existing ALB
alb-mercadofresco-tienda and tg-mercadofresco-tienda have been in production for eight months, created by hand. Marta wants to adopt them into mercadofresco-aplicacion-produccion without taking the service down. Describe the procedure: what to gather beforehand and with which commands, what the template must contain, how the identifiers file is built, which commands are run and in what order, and what is done immediately afterwards. Explain what would happen if the template declared HealthCheckIntervalSeconds: 30 when the real target group has 15, and at exactly which moment it would show up.
Exercise 3: splitting the template monolith
A colleague has written a 780-line template with 96 resources: VPC and subnets, Aurora, two DynamoDB tables, ElastiCache, ALB, ASG, four queues, event bus, twelve alarms and three buckets. It takes 41 minutes and any failure rolls everything back; last week, an error in an alarm also rolled back a perfectly good subnet change. Propose the split: which stacks, with what contents and on what criterion; for each boundary, whether you would use an export or SSM and why; the deployment order and the deletion order; and what would have happened last week with your split.
Solutions
Solution 1
Resources:
ColaPedidosFallidos:
Type: AWS::SQS::Queue
DeletionPolicy: Retain # The DLQ holds unprocessed messages: never deleted with the stack
UpdateReplacePolicy: Retain
Properties:
QueueName: !Sub 'mercadofresco-pedidos-fallidos-${Entorno}'
MessageRetentionPeriod: 1209600 # 14 days, the maximum
KmsMasterKeyId: alias/mercadofresco-datos
Tags: &etiquetas # YAML anchor: CloudFormation accepts aliases and avoids repeating
- { Key: Proyecto, Value: mercadofresco }
- { Key: Entorno, Value: !Ref Entorno }
- { Key: Componente, Value: integracion }
- { Key: Propietario, Value: luis }
- { Key: CentroCoste, Value: plataforma }
ColaPedidos:
Type: AWS::SQS::Queue
Properties: { QueueName: !Sub 'cola-mercadofresco-pedidos-${Entorno}',
MessageRetentionPeriod: 345600, VisibilityTimeout: 180, # 4 days of retention
KmsMasterKeyId: alias/mercadofresco-datos, Tags: *etiquetas,
RedrivePolicy: { deadLetterTargetArn: !GetAtt ColaPedidosFallidos.Arn, maxReceiveCount: 5 } }
TemaPedidoConfirmado:
Type: AWS::SNS::Topic
Properties: { TopicName: !Sub 'mercadofresco-pedido-confirmado-${Entorno}',
KmsMasterKeyId: alias/mercadofresco-datos, Tags: *etiquetas }
SuscripcionColaAlTema:
Type: AWS::SNS::Subscription
Properties: { TopicArn: !Ref TemaPedidoConfirmado, # !Ref of a topic returns the ARN
Protocol: sqs, Endpoint: !GetAtt ColaPedidos.Arn, RawMessageDelivery: true }
PoliticaColaParaSns:
Type: AWS::SQS::QueuePolicy
Properties:
Queues: [ !Ref ColaPedidos ] # !Ref of a queue returns the URL: what is expected here
PolicyDocument:
Version: '2012-10-17'
Statement: [ { Effect: Allow, Action: 'sqs:SendMessage', Resource: !GetAtt ColaPedidos.Arn,
Principal: { Service: sns.amazonaws.com },
Condition: { ArnEquals: { 'aws:SourceArn': !Ref TemaPedidoConfirmado } } } ]
ParametroArnCola:
Type: AWS::SSM::Parameter
Properties: { Type: String, Value: !GetAtt ColaPedidos.Arn,
Name: !Sub '/mercadofresco/${Entorno}/integracion/arn-cola-pedidos' }Changing VisibilityTimeout is no interruption: SQS applies it in place and the URL does not change. Changing QueueName is with replacement: SQS does not allow renaming, so a new queue is created and the old one deleted with the messages inside. It is a perfect example of why pinning physical names has a cost and of why the DLQ carries Retain. And one detail that is easy to forget: without the queue policy, SNS cannot deliver and the messages are lost silently; the aws:SourceArn condition stops any other topic in the account writing to it, the least privilege of 04-01 applied to resources.
Solution 2
Beforehand: read the real configuration, never write it from memory.
aws elbv2 describe-load-balancers --names alb-mercadofresco-tienda > alb-real.json
aws elbv2 describe-target-groups --names tg-mercadofresco-tienda > tg-real.json
aws elbv2 describe-listeners --load-balancer-arn "$ARN_ALB" > listeners-real.json
aws elbv2 describe-target-group-attributes --target-group-arn "$ARN_TG" > tg-attrs.jsonThe template is written from those files, property by property, including the attributes that are not obvious: deregistration_delay.timeout_seconds, stickiness.enabled, the listener's TLS policy and the ACM certificate. Mandatory: DeletionPolicy: Retain on the ALB, the target group and the listener; without it the import is rejected, and it is worth adding UpdateReplacePolicy: Retain too. The identifiers file pairs LogicalResourceId with LoadBalancerArn and TargetGroupArn, using the full ARN (arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/alb-mercadofresco-tienda/a1b2c3).
Order: cfn-lint; create-change-set with --change-set-type IMPORT; describe-change-set to confirm that all the actions are Import and none is Add or Modify — a single Modify means the template does not match reality and has to be fixed first; execute-change-set; wait stack-import-complete. Immediately afterwards: drift detection, resolving case by case whether the template or reality wins. The 30 versus 15 case. The import goes through without complaining: it only verifies existence and type. Drift would flag it as MODIFIED. And if nobody looks, the problem shows up at the next update-stack, whatever it is about, even if it has nothing to do with the target group: CloudFormation applies the complete template and the interval becomes 30 seconds. In practice, the ALB takes twice as long to detect a downed instance — from 30 to 60 seconds with two failed checks — exactly the kind of silent degradation nobody connects with a deployment of something else. That is the entire argument in favour of the drift step.
Solution 3
Five stacks, the same ones from the table in the lesson: red (almost immutable, everything depends on it), datos (state, catastrophic deletion, needs protection), integracion (no durable state, owned by Luis), aplicacion (what changes most) and observabilidad (changes with incidents, zero risk). Boundaries and order. Network → everything: export, because the VPC and subnet identifiers are structural, they are not going to change, and we want the lock: nobody should be able to delete the network while there is anything on top of it. Data → application (Aurora endpoint, table names): SSM, because they change more easily than it seems — a migration to Serverless v2, a read endpoint — and an export would block precisely that. Integration → application: SSM, and also because in 09-04 these stacks are going to live in different accounts, where exports do not work. Everything → observability: SSM; the stack that matters least and changes most must not be able to block anybody.
The deployment order is network → data and integration in parallel → application → observability, and the deletion order is its exact inverse: the exports enforce it on their own in the network's case, and everywhere else it has to be respected out of discipline, because SSM enforces nothing.
Last week. The error in the alarm would have failed only mercadofresco-observabilidad, which deploys in under two minutes and holds no critical resources; the subnet change, in mercadofresco-red, would already have been applied and would not have been rolled back. The scope of the failure goes from 96 resources to 12, and the 41 minutes become four deployments of between 2 and 12 minutes, some of them in parallel. One honest nuance: the split is not free. It introduces a deployment order, it forces the boundaries to be maintained and it makes a change touching two layers need two stacks. The rule that justifies it is the same one from 08-05 about deployments: the scope of a failure must be proportional to the risk of the change, and mixing an alarm with a subnet violates that rule.
Conclusion
MercadoFresco's infrastructure is finally in files. red-mercadofresco.yaml describes the VPC, the six subnets, the internet gateway, the NATs — two in production and one everywhere else, with the same template — and the S3 endpoint, with a history in Git; aplicacion-mercadofresco.yaml builds the ALB, the target group and the auto scaling group on top without knowing a single identifier, importing the outputs of the first. And you have the anatomy of a template, with parameters that validate before anything starts thanks to the AWS-specific types, values arriving from Parameter Store without touching the template and secrets resolved with {{resolve:secretsmanager:...}} instead of the false friend that is NoEcho. You have the intrinsic functions and the table that avoids the most frequent error: that !Ref returns the URL of a queue, the ARN of a topic and the name of a role, and that what you are after is almost always !GetAtt Recurso.Arn. And you have the lifecycle of a stack, with the state that baffles everyone on day one: ROLLBACK_COMPLETE, which can only be deleted.
What really changes the way you work are three mechanisms. Change sets, which answer in writing the question nobody could answer in module 8 — what is going to happen if I apply this? — and which show the Replacement column that separates a routine change from the destruction of a subnet. Deletion and replacement policies plus termination protection, the only thing standing between a command and the Aurora data. And drift detection, which turns manual changes from invisible into a weekly report. With importing existing resources, MercadoFresco recreates nothing: it adopts what already works, layer by layer, starting with the network, and runs drift detection straight afterwards — the step nobody should skip, because importing checks that the resources exist, not that the template describes them properly. The five stacks per environment are split by lifecycle and by ownership, with exports for the structural and SSM for whatever can change or cross accounts, knowing that an export in use is a lock. And the infrastructure goes through the 08-04 pipeline with the same rigour as the code: cfn-lint and cfn-guard that fail the build, a published change set, Marta's approval and execution with RoleArn, so that whoever pulls the trigger does not need the permissions being exercised.
One limitation remains, and it shows up as soon as the template grows. The network describes six almost identical subnets repeated by hand: there are no loops, no functions, no way of writing "create one subnet per AZ" or of encapsulating "MercadoFresco's standard network" into something reusable. Conditions work for two cases and become unreadable beyond five. And there is no way of writing a test that verifies no template opens port 22 to the world: cfn-guard helps, but it is yet another language to learn.
In 09-02, "AWS CDK", that same infrastructure is written in TypeScript and in Python, with loops, conditionals, types, reusable constructs and real unit tests, and the result is still a CloudFormation template deployed as a stack. Everything in this lesson remains true underneath: the CDK does not replace CloudFormation, it writes it for you.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
