Deploy Django on AWS with CloudFormation (IaC)

Blog / Amazon Web Services · December 10, 2021 · Updated June 10, 2026 · 11 min read
Deploy Django on AWS with CloudFormation (IaC)

Treating infrastructure as code (IaC) means your AWS environment lives in a version-controlled template instead of a sequence of console clicks. For a Django app that usually involves a VPC, an EC2 fleet, a managed database, security groups, and IAM roles — exactly the kind of setup you do not want to recreate by hand for staging, production, and disaster recovery.

AWS CloudFormation is Amazon's native IaC service. You describe the resources you want in a declarative YAML (or JSON) template, hand it to CloudFormation, and it provisions and tracks them as a single unit called a stack. Change the template and CloudFormation works out the delta, then applies only what changed.

This guide walks through a realistic, production-shaped CloudFormation template for Django on AWS — an Application Load Balancer in front of an Auto Scaling Group of EC2 instances, backed by RDS PostgreSQL — plus how to deploy it with the AWS CLI v2, keep secrets out of the template, and run migrations on release. If you would rather hand provisioning to a managed platform, see our companion guide on deploying Django on Elastic Beanstalk; we contrast the two approaches below.

CloudFormation in one minute: templates and stacks

A CloudFormation template is a declarative document that lists the AWS resources you want and how they relate. You submit it and CloudFormation creates a stack — a single managed collection of those resources. The stack becomes the unit you create, update, and delete as one.

Two ideas make this powerful:

  • Declarative, not imperative. You describe the desired end state; CloudFormation figures out the order to create things (it builds a dependency graph from your references) and rolls everything back automatically if any resource fails.
  • Idempotent updates. Re-applying the same template is a no-op. Changing it produces a precise diff you can preview with a change set before it touches production.

Because the template is just text, it belongs in your Git repo next to the Django code, gets code-reviewed, and is reproducible across regions and accounts.

Anatomy of a CloudFormation template

Every template is organised into a handful of top-level sections. Only Resources is mandatory:

  • Parameters — typed inputs supplied at deploy time (instance type, VPC id, environment name). They keep one template reusable across environments.
  • Mappings — static lookup tables, e.g. a region-to-AMI map (largely replaced today by SSM public parameters — see below).
  • Conditions — boolean expressions that toggle resources or properties (for example, only attach a larger instance type in prod).
  • Resources — the actual AWS resources to provision. This is the only required section.
  • Outputs — values to surface after the stack is built, such as the load balancer DNS name or the database endpoint.

You wire sections together with intrinsic functions: !Ref returns a parameter value or a resource's primary id, !GetAtt reads a resource attribute (like an instance profile ARN), and !Sub interpolates values into strings.

A realistic Django stack

The template below provisions a small but production-shaped Django environment:

  • An Application Load Balancer (ALB) that terminates public traffic.
  • An Auto Scaling Group (ASG) of EC2 instances running Gunicorn, registered to the ALB's target group, so the fleet scales and self-heals. Our deep dive on auto scaling groups behind a load balancer covers this pattern in detail.
  • An RDS PostgreSQL instance for the database, with a Snapshot deletion policy so you do not lose data on a stack teardown.
  • Security groups that only let the web tier receive traffic from the ALB.
  • An IAM role and instance profile so instances can read secrets and be managed by SSM — no SSH keys required. See AWS IAM roles and policies for how least-privilege roles are structured.

To keep the example focused it deploys into an existing VPC and subnets (your account's default VPC works fine to start). The AMI is resolved at deploy time from a public SSM parameter, so you always get the latest patched Amazon Linux 2023 image instead of a hardcoded id.

AWSTemplateFormatVersion: "2010-09-09"
Description: Django stack - ALB + Auto Scaling Group + RDS PostgreSQL (IaC)

Parameters:
  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: VPC to deploy into (your default VPC works to start)
  Subnets:
    Type: List<AWS::EC2::Subnet::Id>
    Description: Two or more subnets across Availability Zones
  InstanceType:
    Type: String
    Default: t3.small
  LatestAmiId:
    # AMI resolved at deploy time from a public SSM parameter (always patched)
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64

Resources:
  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Public HTTP to the ALB
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0

  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: App traffic from the ALB only
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 8000
          ToPort: 8000
          SourceSecurityGroupId: !Ref AlbSecurityGroup

  InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ec2.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles: [!Ref InstanceRole]

  Database:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    Properties:
      Engine: postgres
      DBInstanceClass: db.t3.micro
      AllocatedStorage: "20"
      DBName: appdb
      MasterUsername: appuser
      # Pull the password from Secrets Manager at deploy time - never hardcode it
      MasterUserPassword: "{{resolve:secretsmanager:django/prod/db:SecretString:password}}"
      VPCSecurityGroups: [!Ref WebSecurityGroup]

  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        ImageId: !Ref LatestAmiId
        InstanceType: !Ref InstanceType
        IamInstanceProfile: { Arn: !GetAtt InstanceProfile.Arn }
        SecurityGroupIds: [!Ref WebSecurityGroup]
        UserData:
          Fn::Base64: !Sub |
            #!/bin/bash
            dnf install -y python3.12 git
            # clone code, create a venv, install requirements, then:
            export DB_HOST=${Database.Endpoint.Address}
            python3.12 manage.py migrate --noinput
            python3.12 manage.py collectstatic --noinput
            gunicorn config.wsgi -b 0.0.0.0:8000

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref VpcId
      Port: 8000
      Protocol: HTTP
      HealthCheckPath: /healthz/

  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Subnets: !Ref Subnets
      SecurityGroups: [!Ref AlbSecurityGroup]

  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: "2"
      MaxSize: "4"
      DesiredCapacity: "2"
      VPCZoneIdentifier: !Ref Subnets
      TargetGroupARNs: [!Ref TargetGroup]
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber

Outputs:
  SiteUrl:
    Description: Public URL of the Django app
    Value: !Sub "http://${LoadBalancer.DNSName}"

Deploying the stack with the AWS CLI v2

The modern way to ship a template is aws cloudformation deploy. It creates the stack if it does not exist and updates it if it does — one idempotent command that fits cleanly into CI/CD. Because the template creates IAM resources, you pass --capabilities CAPABILITY_IAM to acknowledge that.

Before letting an update touch production, generate a change set: a preview of exactly what CloudFormation will add, modify, or replace. Reviewing change sets is the single most effective habit for avoiding surprise replacements, because some property changes force a resource to be recreated (which can mean downtime or data loss).

# Validate the template before anything else
aws cloudformation validate-template --template-body file://django-stack.yaml

# Create OR update the stack idempotently (AWS CLI v2)
aws cloudformation deploy \
  --stack-name django-prod \
  --template-file django-stack.yaml \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides VpcId=vpc-0abc123 Subnets=subnet-0a,subnet-0b

# Preview changes safely with a change set before they apply
aws cloudformation create-change-set \
  --stack-name django-prod --change-set-name release-2026-06 \
  --template-body file://django-stack.yaml \
  --capabilities CAPABILITY_IAM
aws cloudformation describe-change-set \
  --stack-name django-prod --change-set-name release-2026-06

# Detect manual ("out of band") changes that drifted from the template
aws cloudformation detect-stack-drift --stack-name django-prod

Keep secrets out of the template

Never hardcode database passwords, Django's SECRET_KEY, or API keys in a template — templates land in Git and in the CloudFormation console as plain text. Instead, store them in AWS Secrets Manager or SSM Parameter Store and pull them in with a dynamic reference.

In the template above the RDS password uses {{resolve:secretsmanager:django/prod/db:SecretString:password}}, which CloudFormation resolves at deploy time without ever printing the value. For inputs you do pass as parameters, mark them NoEcho: true. Create the secret once, out of band:

# Create the secret once; rotate it later via Secrets Manager
aws secretsmanager create-secret \
  --name django/prod/db \
  --secret-string '{"password":"REPLACE_WITH_A_STRONG_PASSWORD"}'

# At runtime, instances read it with boto3 using their IAM role -
# nothing sensitive is ever baked into the AMI or the template:
#   import boto3, json
#   sm = boto3.client("secretsmanager")
#   secret = json.loads(sm.get_secret_value(SecretId="django/prod/db")["SecretString"])

Running migrations on deploy

Provisioning infrastructure and releasing application code are two different jobs, and migrate should run exactly once per release — not on every instance boot. A few patterns, from simplest to most robust:

  • UserData (simple). The launch template's UserData pulls the code, installs dependencies, runs python3 manage.py migrate --noinput and collectstatic, then starts Gunicorn. Fine for a single instance or low-traffic app, but it runs on every instance that launches — Django migrations are idempotent, but you still risk races.
  • A one-off migration task (recommended). Run migrations from a single place — a CodeDeploy lifecycle hook, a one-off ECS task, or a CI step — before traffic shifts to the new version. This avoids race conditions when several instances boot at once.
  • CodeDeploy integrates with the Auto Scaling Group for blue/green or rolling releases and gives you a BeforeAllowTraffic hook — the right place to run migrations and smoke tests.

Whatever you choose, keep migrations backward-compatible so old and new code can run side by side during a rolling deploy.

CloudFormation vs CDK vs Terraform vs Elastic Beanstalk

CloudFormation is not the only way to provision a Django stack. Here is how the main options compare:

CloudFormation AWS CDK Terraform Elastic Beanstalk
Approach Declarative IaC Imperative code that synthesises CloudFormation Declarative IaC (third party) Managed PaaS
Authoring YAML / JSON Python, JavaScript, Go, Java HCL Minimal config (.ebextensions)
AWS coverage Full, native, same-day Full (compiles to CloudFormation) Full, plus other clouds AWS-managed subset
State Managed by AWS Managed by AWS (via CFN) You manage state files Managed by AWS
Best for AWS-only teams wanting native IaC Django teams who prefer Python over YAML Multi-cloud or existing Terraform shops Shipping fast with little ops

Two takeaways for Django teams. First, the AWS CDK lets you author the very same CloudFormation in Python — appealing if your team already lives in Python and wants loops, conditionals, and unit tests around infrastructure. Second, if you want AWS to provision and manage the platform for you rather than describing every resource yourself, Elastic Beanstalk is the managed-PaaS alternative — see our guide to deploying Django on Elastic Beanstalk. CloudFormation sits in the middle: full declarative control of raw resources, without a third-party tool or state file to manage.

CloudFormation best practices

  • Never hardcode secrets. Use Secrets Manager or SSM Parameter Store with dynamic references; mark sensitive parameters NoEcho: true.
  • Always preview with change sets before updating a production stack.
  • Resolve AMIs from SSM public parameters instead of pinning ids in Mappings, so you inherit the latest patched image.
  • Use !Ref and !GetAtt to wire resources together rather than hardcoding ARNs or ids — it keeps the dependency graph (and rollback order) correct.
  • Run drift detection regularly to catch manual console changes that diverge from the template.
  • Set a sensible DeletionPolicy (e.g. Snapshot on RDS) so a stack delete cannot wipe data.
  • Split large templates into nested stacks (networking, data, app) and parameterise per environment instead of copy-pasting templates.
  • Let rollback do its job — CloudFormation reverts a failed update to the last good state automatically; keep that behaviour on for production.

Need a hand with Django on AWS?

For 12+ years and across 50+ delivered projects, MicroPyramid has shipped Django applications on AWS with infrastructure as code, repeatable environments, and least-privilege IAM baked in from day one. We help teams stand up CloudFormation (or CDK) stacks, harden them, and wire them into CI/CD — and we have helped clients cut cloud spend by around 30% along the way.

Explore our AWS consulting services, cloud migration services, and Django development services — or read AWS tips and tricks to optimise cost and performance for more ways to get better ROI from your stack.

Frequently Asked Questions

Should I use CloudFormation or Elastic Beanstalk for Django?

Use Elastic Beanstalk when you want AWS to provision and manage the platform for you with minimal ops effort, and CloudFormation when you need explicit, declarative control over every resource (VPC, ALB, ASG, RDS, IAM) as version-controlled infrastructure as code. They are not mutually exclusive — Elastic Beanstalk itself uses CloudFormation under the hood, and you can even describe Beanstalk environments inside a CloudFormation template. For full detail on the managed-PaaS route, see our companion guide on deploying Django on Elastic Beanstalk.

CloudFormation or Terraform, which should a Django team pick?

Pick CloudFormation if you are AWS-only and want a native, AWS-managed service with no separate state file to store and lock. Pick Terraform if you run multiple clouds, already have Terraform expertise, or want its larger ecosystem of providers. Both are declarative and production-grade; the deciding factors are usually existing team skills and whether you are committed to a single cloud.

How do I handle Django secrets in a CloudFormation template?

Never put passwords or the Django SECRET_KEY in the template. Store them in AWS Secrets Manager or SSM Parameter Store and pull them in with a dynamic reference such as the secretsmanager resolve syntax, which CloudFormation substitutes at deploy time without exposing the value. At runtime, instances fetch secrets with boto3 using an IAM role, so nothing sensitive is ever baked into the template or the AMI.

How do I run Django migrations during a CloudFormation deploy?

Run migrations exactly once per release, ideally from a single place such as a CodeDeploy BeforeAllowTraffic hook, a one-off ECS task, or a CI step, before traffic shifts to the new version. Running migrate in EC2 UserData works for a single instance, but in an Auto Scaling Group several instances can boot at once and race, so a dedicated one-off task is safer. Keep every migration backward-compatible so old and new code can run together during a rolling deploy.

Should I write CloudFormation templates in JSON or YAML?

Prefer YAML. It is far more readable, supports comments, and has concise short forms for intrinsic functions like !Ref and !GetAtt. JSON is still valid and is sometimes generated by tooling, but for hand-authored templates YAML is the modern default. If you would rather write infrastructure in Python, the AWS CDK synthesises CloudFormation for you.

What is a CloudFormation change set?

A change set is a preview of exactly what CloudFormation will do to a stack before it does it — which resources will be added, modified, or replaced. Because some property changes force a resource to be recreated (which can mean downtime or data loss), reviewing a change set before updating a production stack is the most important safety habit in CloudFormation. Create one with create-change-set, inspect it with describe-change-set, then execute or discard it.

Share this article