CI/CD Pipeline for Django + Next.js

Automate testing, staging, and deployment for a Django and Next.js app.

Introduction

30 Second Summary

Every time you push code to GitHub and then manually deploy it, you risk breaking something in production. Teams solve this by automating the entire journey from code change to live deployment.

In this project, you will build a complete CI/CD pipeline that automatically tests your Django + Next.js application, spins up isolated staging environments per pull request, and deploys to production on AWS when code is merged.

What You'll Build

A production-grade deployment pipeline using GitHub Actions, NeonDB branching, Vercel preview deployments, Docker, and Amazon ECS Express Mode.

By the end of this project, you'll have:

  • An automated CI pipeline that runs tests and linting on every push, blocking broken code from merging.
  • A staging environment per pull request with its own database branch, preview frontend, and Stripe test keys.
  • An automated production deployment that builds a Docker image, pushes to Amazon ECR, and deploys to ECS on merge to main.
  • Secret Mission: Add schema diff comments to your pull requests showing exactly what database changes each PR introduces.

Are there any prerequisites?

This project assumes you have an existing Django + Next.js codebase with Stripe integration. You also need basic Git knowledge including branching, commits, and pull requests.

Configure Your Project Structure for CI/CD

Before you build automation, your project needs a clear structure that separates concerns. GitHub Actions workflows live in a specific directory, and environment variables need explicit boundaries between local development, staging, and production.

In this step, you'll create the directory where your CI/CD workflow files will live, set up local environment files with proper separation, and confirm your NeonDB and Stripe configurations are ready for pipeline automation.

In this step, get ready to:
  • Create the .github/workflows directory for CI/CD workflow files.
  • Set up environment variable files for local development.
  • Verify your NeonDB production branch and Stripe test keys.
Create the GitHub Actions directory

GitHub Actions looks for workflow files in a specific location: the .github/workflows/ directory at the root of your repository. Without this directory, GitHub won't recognize any CI/CD workflows you define.

  • Open your terminal in your project's root directory (the folder that contains your manage.py and package.json).
  • Create the workflows directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github

You should see workflows in the output.

Why is the folder hidden?

Directories that start with a dot (like .github) are hidden by default on macOS and Linux. They won't appear in a regular Finder or File Explorer view, but your terminal and code editor can see them.

Don't see the workflows folder?

Make sure you're running the command from your project's root directory, not from inside a subdirectory like src/ or frontend/.

help me find my project root directory

Set up local environment variable files

Your Django backend and Next.js frontend each need their own environment variables. Keeping these in separate files makes it clear which values belong to which part of your stack.

  • Create a file called .env in your project root for Django's local development settings.
  • Add the following content, replacing the placeholder values with your own credentials:
# Django local development settings
DATABASE_URL=your-neon-dev-branch-connection-string
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
DEBUG=True
SECRET_KEY=your-django-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1

What does each variable do?

  • DATABASE_URL points to your NeonDB development branch. Find this connection string in your Neon Console under **Connection Details**.
  • STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY are your Stripe test mode keys. Test keys always start with sk_test_ and pk_test_ respectively.
  • DEBUG=True enables detailed error pages during local development.
  • SECRET_KEY is Django's cryptographic signing key. Use the one from your existing local setup.
  • ALLOWED_HOSTS restricts which domains can serve your Django app.
  • Save .env.
  • Now create a file called .env.local in your project root for your Next.js frontend settings.
  • Add the following content:
# Next.js local development settings
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key

What does each variable do?

  • NEXT_PUBLIC_API_URL tells your Next.js app where to find the Django backend. Locally, that's http://localhost:8000.
  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is the client-side Stripe key your frontend uses to create payment elements. The NEXT_PUBLIC_ prefix makes it available in the browser.
  • Save .env.local.
  • Open your .gitignore file and make sure the following entries are present:
.env
.env.local

Why add these to .gitignore?

Your .env files contain secrets like API keys and database credentials. Adding them to .gitignore prevents them from being committed to your repository where anyone with access could see them.

  • Save .gitignore.
  • Confirm both env files exist and are hidden from Git by running:
git status

You should see .gitignore listed as modified (if you added entries), but .env and .env.local should NOT appear in the output. That confirms Git is ignoring them.

Seeing .env in git status?

If .env or .env.local appears in the git status output, your .gitignore entries might not be saved. Double-check that you added .env and .env.local on separate lines and saved the file.

If you previously committed these files, you'll need to remove them from Git's tracking first with git rm --cached .env .env.local.

help me fix my .gitignore so my env files are hidden from Git

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: .env, .env.local, and .gitignore.

ⓧ I'd like to double check the full code

Here are the full contents of each file:

# Django local development settings
DATABASE_URL=your-neon-dev-branch-connection-string
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
DEBUG=True
SECRET_KEY=your-django-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1
# Next.js local development settings
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key

Your .gitignore should include these lines (among others you may already have):

.env
.env.local
Verify your NeonDB production branch and Stripe test keys

Your CI/CD pipeline will create database branches from a parent called production. Every staging branch and every preview environment will fork from this single source of truth.

  • Open the Neon Console at console.neon.tech.
  • Select your project.
  • Click Branches in the left sidebar.

You should see a branch named production with a DEFAULT tag next to it.

✔️ I see production with a DEFAULT tag

Your NeonDB project is configured correctly. The production branch is your default, and all future CI/CD branches will be created from it.

ⓧ I don't see a production branch as default

If your default branch has a different name (like main), you can either rename it or set a different branch as default.

  • Click on the branch you want to use as your production branch.
  • Click the More dropdown menu on the branch overview page.
  • Select Set as default.
  • Click Set as default in the confirmation dialog.

If you also want to rename the branch to production, click More and select Rename.

Why a production branch?

NeonDB branches are copy-on-write clones that take about one second to create, regardless of database size. Each branch gets its own connection string and is completely isolated from other branches.

By designating one branch as production, you establish a clear parent for all staging branches. Your CI/CD pipeline will automatically fork from this branch for every pull request.

Now verify your Stripe test keys are working.

  • Start your Django development server locally.
  • Start your Next.js development server locally.
  • Navigate to a page in your app that triggers a Stripe payment flow.
  • Use the test card number 4242 4242 4242 4242 with any future expiry date and any CVC.
  • Confirm the payment succeeds.

How do Stripe test keys work?

Stripe test keys (prefixed with sk_test_ and pk_test_) operate in a completely isolated sandbox. Objects created in test mode are not accessible in live mode, and no real charges are made.

The card number 4242 4242 4242 4242 is Stripe's standard test card that always produces a successful payment.

Stripe payment failing in test mode?

Check that your STRIPE_SECRET_KEY in .env starts with sk_test_ (not sk_live_). Live keys will reject the test card number.

Also verify your STRIPE_PUBLISHABLE_KEY in .env.local starts with pk_test_. Make sure you restarted both servers after creating the env files.

help me debug why my Stripe test payment is failing

Your project structure is ready for automation. Next up, you'll build the CI pipeline that runs your Django tests automatically on every push to GitHub.

Build the CI Pipeline with GitHub Actions

Right now, the only thing standing between a bug and your production branch is you remembering to run tests locally. That is not a reliable safety net.

A CI/CD pipeline automates that checking process. Every time you push code to GitHub, a GitHub Actions workflow runs your tests and linting automatically. If anything fails, the workflow blocks the merge.

In this step, get ready to:
  • Create a GitHub Actions workflow that runs pytest on every push.
  • Add ruff linting to enforce Python code quality automatically.
  • Push the workflow to GitHub and verify it passes.
Create the CI workflow file

GitHub Actions workflows are YAML files that live in the .github/workflows/ directory. Each file defines when the workflow runs and what jobs it executes.

  • Create a new file called ci.yml inside your .github/workflows/ directory.
  • Paste the following workflow configuration into ci.yml:
name: CI

on:
  push:
    branches: ['**']
  pull_request:
    branches: ['**']

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        env:
          DATABASE_URL: ${{ secrets.NEON_TEST_DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY_TEST }}
          SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
          DEBUG: 'True'
        run: pytest

What does this workflow do?

The on section triggers this workflow on every push and pull request to any branch. The '**' glob means all branches, not just main.

The test job checks out your code, sets up Python 3.12, installs your project dependencies, then runs pytest. The env block injects secrets as environment variables so your Django app can connect to a NeonDB test branch during CI.

Why use secrets for the database URL?

Your NeonDB connection string contains credentials. GitHub Secrets encrypt these values and only expose them to the workflow at runtime. They never appear in logs or your codebase.

Add linting with ruff

Ruff is a Python linter that catches common errors like unused imports and style issues. Adding it to the workflow means code quality is enforced on every push, not just when you remember to check.

  • First, make sure ruff is listed in your requirements.txt so the workflow can install it.
  • In ci.yml, find the Install dependencies step.
  • Find this line:
      - name: Run tests
  • Add the following linting step directly above the Run tests step, so linting runs before your tests:
      - name: Lint with ruff
        run: ruff check .

What does ruff check do?

The ruff check . command lints all Python files in the current directory recursively. If it finds violations (unused imports, syntax issues, style errors), it exits with a non-zero code and the workflow fails. This blocks the merge until the code is clean.

  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved ci.yml and that the linting step appears between Install dependencies and Run tests.

ⓧ I'd like to double check the full code

name: CI

on:
  push:
    branches: ['**']
  pull_request:
    branches: ['**']

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Lint with ruff
        run: ruff check .

      - name: Run tests
        env:
          DATABASE_URL: ${{ secrets.NEON_TEST_DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY_TEST }}
          SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
          DEBUG: 'True'
        run: pytest
Push the workflow to GitHub and verify it runs

The workflow references three GitHub Secrets that do not exist yet. You need to add them before the workflow can pass.

  • Open your repository on GitHub.
  • Click Settings in the repository navigation bar.
  • In the Security section of the sidebar, select Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.

Add each of the following secrets one at a time. For each secret, type the name in the Name field, paste the value in the Secret field, then click Add secret.

  • NEON_TEST_DATABASE_URL - your NeonDB test branch connection string (the same DATABASE_URL from your local .env file works here for now).
  • STRIPE_SECRET_KEY_TEST - your Stripe test secret key (starts with sk_test_).
  • DJANGO_SECRET_KEY - your Django SECRET_KEY value from your local .env file.

What are GitHub Secrets?

GitHub Secrets are encrypted environment variables that only get injected into workflows at runtime. They never appear in logs, pull request diffs, or the repository source code. This is how you safely give your CI pipeline access to database credentials and API keys.

Now push your workflow file to GitHub.

  • Stage, commit, and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI workflow with pytest and ruff"
git push
  • Go to your repository on GitHub and click the Actions tab.
  • You should see a workflow run triggered by your push. Click on it to watch it execute.

After a minute or two, the workflow should complete with a green checkmark. This confirms your tests pass and your code is lint-free.

Workflow showing a red X?

Check the workflow logs by clicking the failed job. Common issues:

  • Missing secret: if you see a database connection error, double check that NEON_TEST_DATABASE_URL is spelled exactly right in both your secret name and the workflow file.
  • Ruff violations: if linting fails, run ruff check . locally to see what needs fixing. Address the issues and push again.
  • Missing ruff in requirements.txt: if you see a "command not found" error for ruff, add ruff to your requirements.txt and push again.

Still stuck? Help me debug my CI workflow failure

Your CI pipeline is live. Every push now runs through automated linting and testing before anyone can merge. Next up, you will automate database branching so every pull request gets its own isolated staging database.

Set Up NeonDB Branch-Per-PR Automation

Your CI pipeline catches bugs on every push. But right now, every test run shares the same test database.

In a team environment, this causes conflicts when multiple developers push at the same time. The solution is giving every pull request its own isolated database branch. NeonDB creates branches as instant copy-on-write clones in about one second, regardless of database size.

In this step, get ready to:
  • Add your Neon API key and project ID to GitHub so workflows can manage branches.
  • Create a workflow that automatically creates and deletes database branches per pull request.
  • Test the automation by opening a real pull request.
Add your Neon API key and project ID to GitHub

The branching workflow needs two pieces of information from your Neon account: an API key to authenticate requests, and a project ID to know which project to create branches in.

You will store the API key as a GitHub Secret (encrypted, hidden in logs) and the project ID as a GitHub Variable (visible, non-sensitive).

  • Open the Neon Console at https://console.neon.tech.
  • Click your profile icon in the top left, then select Account settings.
  • Click API keys in the left sidebar.
  • Click Create new API key.
  • Give it a name like github-actions and confirm.
  • Copy the key immediately. It is only shown once.

Why is the API key only shown once?

Neon stores a hashed version of your key for security. If you lose it, you will need to revoke the old key and create a new one.

  • Now find your project ID. Go back to the Neon Console home, open your project, then click Settings in the left sidebar.
  • On the General page, copy the Project ID value.

Now add both values to your GitHub repository.

  • Open your repository on GitHub.
  • Click Settings in the repository navigation bar.
  • In the left sidebar under the Security section, click Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • Enter NEON_API_KEY as the name and paste your Neon API key as the value.
  • Click Add secret.
  • Now switch to the Variables tab on the same page.
  • Click New repository variable.
  • Enter NEON_PROJECT_ID as the name and paste your Neon project ID as the value.
  • Click Add variable.

Why a Variable instead of a Secret?

The project ID is not sensitive. Storing it as a Variable (accessed via ${{ vars.NEON_PROJECT_ID }}) makes it visible in workflow logs for easier debugging. Secrets (accessed via ${{ secrets.NEON_API_KEY }}) are always redacted in logs.

You should now see NEON_API_KEY listed on the Secrets tab and NEON_PROJECT_ID on the Variables tab.

Don't see the Secrets and variables option?

You need admin or write access to the repository. If you are working with a fork, make sure you are in your fork's settings, not the upstream repository's.

help me find the Secrets and variables setting in my GitHub repository

Create the Neon branching workflow

This workflow listens for pull request events. When a PR is opened, it creates a brand new NeonDB branch named after the PR number. When the PR is closed (merged or abandoned), it deletes that branch to keep your project tidy.

  • Create a new file at .github/workflows/neon-branch.yml in your project and add the following workflow:
name: Neon Branch Management

on:
  pull_request:
    types: [opened, reopened, synchronize, closed]

jobs:
  create-branch:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Create Neon branch
        uses: neondatabase/create-branch-action@v5
        with:
          project_id: ${{ vars.NEON_PROJECT_ID }}
          branch_name: preview/pr-${{ github.event.number }}
          api_key: ${{ secrets.NEON_API_KEY }}

  delete-branch:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Delete Neon branch
        uses: neondatabase/delete-branch-action@v3
        with:
          project_id: ${{ vars.NEON_PROJECT_ID }}
          branch: preview/pr-${{ github.event.number }}
          api_key: ${{ secrets.NEON_API_KEY }}

What does this workflow do?

  • The on: pull_request trigger fires on four event types: opened, reopened, synchronize (new commits pushed to the PR), and closed.
  • The create-branch job runs for any event except closed. It uses the official neondatabase/create-branch-action@v5 to create a branch named preview/pr-<PR-number> from your production branch.
  • The delete-branch job runs only when the PR is closed. It uses neondatabase/delete-branch-action@v3 to remove the temporary branch.
  • Each branch is a full copy-on-write clone of your production database, created in about one second regardless of size.
  • Save neon-branch.yml.

✔️ Awesome, I've got everything!

Great. Double check that neon-branch.yml is saved in the .github/workflows/ directory alongside your existing ci.yml.

ⓧ I'd like to double check the full code

name: Neon Branch Management

on:
  pull_request:
    types: [opened, reopened, synchronize, closed]

jobs:
  create-branch:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Create Neon branch
        uses: neondatabase/create-branch-action@v5
        with:
          project_id: ${{ vars.NEON_PROJECT_ID }}
          branch_name: preview/pr-${{ github.event.number }}
          api_key: ${{ secrets.NEON_API_KEY }}

  delete-branch:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Delete Neon branch
        uses: neondatabase/delete-branch-action@v3
        with:
          project_id: ${{ vars.NEON_PROJECT_ID }}
          branch: preview/pr-${{ github.event.number }}
          api_key: ${{ secrets.NEON_API_KEY }}
Test the workflow by opening a pull request

Time to see the automation in action. You will push your new workflow file and open a pull request to trigger it.

  • Create a new branch and commit the workflow file by running these commands:
git checkout -b test/neon-branching
git add .github/workflows/neon-branch.yml
git commit -m "Add Neon branch-per-PR workflow"
git push origin test/neon-branching
  • Open your repository on GitHub.
  • You should see a banner suggesting you create a pull request for the test/neon-branching branch. Click Compare & pull request.
  • Give the PR a title (e.g. Test Neon branch automation) and click Create pull request.
  • Wait 30-60 seconds, then open the Actions tab in your repository. You should see a "Neon Branch Management" workflow run with a green checkmark.
  • Open the Neon Console, navigate to your project, and click Branches in the left sidebar.
  • Verify you see a new branch with the preview/pr- prefix followed by your PR number.

Don't see the Neon branch?

  • Check the Actions tab for a failed workflow run. Click into the run to see error details.
  • Verify that NEON_API_KEY is listed under repository Secrets (not Variables) and NEON_PROJECT_ID is listed under Variables (not Secrets).
  • Confirm the workflow file is committed on the branch the PR was opened from. GitHub only runs workflows that exist in the PR's head branch.

help me debug why my Neon branch workflow is failing in GitHub Actions

Your database now branches automatically with every PR. Next up, you will connect Vercel so your frontend also gets a preview deployment per pull request.

Connect Vercel for Preview Deployments

In the previous step, you set up NeonDB branch-per-PR automation so every pull request gets its own isolated database.

Now your backend has staging databases, but your frontend still has nowhere to go. Vercel solves this by automatically deploying a unique preview URL for every pull request. This means reviewers can see real UI changes alongside the database branch your workflow just created.

In this step, get ready to:
  • Import your Next.js frontend into Vercel and connect it to your GitHub repository.
  • Configure environment variables scoped to Preview and Production deployments.
  • Verify that Vercel posts a preview URL comment on a pull request.
Import your Next.js project to Vercel

Vercel provides zero-config deployment for Next.js apps. Once connected to your GitHub repository, it automatically deploys every push to a preview URL and every merge to main to production.

  • Open your browser and go to vercel.com/dashboard.
  • Click the Add New... dropdown button near the top right of your dashboard.
  • Select Project.
  • You will see a list of Git repositories your GitHub account has access to. Find your project repository and click Import next to it.

Don't see your repository?

If this is your first time using Vercel, it will ask you to authorize access to your GitHub account. Click Install on the GitHub permissions prompt and grant access to the repository you want to import.

  • On the configuration page, set the Framework Preset to Next.js.
  • If your Next.js code lives in a subdirectory (not the repo root), click Edit next to Root Directory and enter the path to your Next.js folder.
  • Leave the build and output settings at their defaults.
  • Click Deploy.

Vercel will build and deploy your Next.js app. Wait for the deployment to complete. You should see a success screen with a preview of your deployed site.

Why Vercel for the frontend?

Vercel is built and maintained by the same team that builds Next.js. It provides zero-config deployments, automatic preview URLs per pull request, and environment variable scoping across Preview and Production.

Deploying the Next.js frontend to AWS alongside Django would add significant complexity without teaching new CI/CD concepts. The Django backend on AWS teaches containerization and cloud deployment skills in a later step.

Deployment failed?

Check the build logs on Vercel for the specific error. Common causes include missing dependencies in package.json or an incorrect root directory setting.

help me debug my Vercel deployment failure

Configure environment variables for Preview and Production

Your Next.js frontend needs to know where the Django API lives. In local development, that is http://localhost:8000. For preview deployments, it should point to your staging backend. For production, it should point to your production backend.

  • From your Vercel project dashboard, click Settings in the sidebar.
  • Select Environment Variables from the left navigation.

You will add two variables scoped to the Preview environment and one to the Production environment.

  • In the Key field, enter NEXT_PUBLIC_API_URL.
  • In the Value field, enter your staging Django backend URL (this will be the URL you get from ECS in a later step, so use a placeholder like https://staging-api.example.com for now).
  • In the Environments dropdown, deselect Production and keep only Preview selected.
  • Click Save.
  • Add a second variable with the key NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.
  • Set the value to your Stripe test publishable key (starts with pk_test_).
  • Set the environment to Preview only.
  • Click Save.
  • Add a third variable with the key NEXT_PUBLIC_API_URL again.
  • Set the value to your production Django backend URL (you will update this in a later step once ECS is running).
  • Set the environment to Production only.
  • Click Save.

How does Vercel scope environment variables?

Vercel lets you assign each environment variable to one or more environments: Production, Preview, and Development. Preview variables apply to deployments from any branch that is not your production branch.

This means your preview deployments automatically use Stripe test keys and a staging API, while production uses live keys and the real backend. No code changes needed between environments.

Not sure what URL to use for the staging backend?

You have not deployed your Django backend to AWS yet. Enter a placeholder URL for now. You will come back and update this value after completing Step 8 (Deploy Django to ECS Express Mode).

help me understand when to update my Vercel environment variables

Verify the preview deployment on a pull request

Vercel automatically deploys every push to a non-production branch and posts a unique preview URL as a comment on the pull request. Let's confirm this is working.

  • If you still have the pull request open from Step 3, push a small commit to it (even a whitespace change in a comment will work).
  • If you closed that PR, create a new feature branch and open a fresh pull request against main.
  • Go to your pull request on GitHub.
  • Wait 1-2 minutes for the Vercel bot to post a comment with a preview URL.
  • Click the preview URL in the comment.

You should see your Next.js frontend deployed and running at the preview URL. The page may show an error if it cannot reach the Django backend (since the staging URL is a placeholder), but the frontend itself should load.

No Vercel comment on your PR?

Make sure your repository is connected to Vercel (check Settings > Git in your Vercel project dashboard). Also verify the branch you pushed to is not the production branch (main). Pushes to the production branch create production deployments, not preview deployments.

help me troubleshoot why Vercel is not commenting on my pull request

Your frontend now has automated preview deployments for every pull request. Next up, you will configure Stripe environment separation so preview deployments use test keys while production uses live keys.

Configure Stripe Environment Separation

Your CI pipeline and preview deployments are running. The next challenge is making sure your Stripe integration never accidentally charges real customers during testing.

Stripe uses separate API keys for test mode and live mode. Objects created with test keys are completely isolated from live mode. In this step, you will verify your Stripe test key setup across environments, confirm it works on a preview deployment, and document the full mapping so your team always knows which keys go where.

In this step, get ready to:
  • Verify Stripe test keys are configured in your staging and CI environments.
  • Test a payment on your preview deployment using the Stripe test card.
  • Document the environment variable mapping across dev, staging, and production.
Verify Stripe test keys in your staging environment

For Stripe to stay in test mode during staging, every environment that processes payments needs test keys. Your Vercel Preview environment and GitHub Secrets already have most of these configured from previous steps.

  • Open the Vercel dashboard and select your project.
  • Click Settings in the sidebar, then select Environment Variables.
  • Confirm that NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is set for the Preview environment with a value starting with pk_test_.

Why confirm what already exists?

Stripe key prefixes tell you exactly which mode the key belongs to. Keys starting with pk_test_ and sk_test_ are test mode. Keys starting with pk_live_ and sk_live_ are live mode.

Mixing these up means either test payments fail silently or real customers get charged during development. Verifying now saves hours of debugging later.

Now add the production publishable key placeholder so Vercel's Production environment is ready when you deploy to live.

  • In the same Environment Variables section, click Add New.
  • Set the key to NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.
  • Set the value to your Stripe live publishable key (starts with pk_live_). If you do not have a live key yet, enter a placeholder like pk_live_placeholder for now.
  • Select only the Production environment checkbox.
  • Click Save.
  • Also confirm in your GitHub repository that STRIPE_SECRET_KEY_TEST exists in Settings > Secrets and variables > Actions > Repository secrets. This secret is referenced in your ci.yml workflow to run tests against Stripe's sandbox.

What is the sk_test_ secret used for?

Your CI workflow uses STRIPE_SECRET_KEY_TEST (containing an sk_test_ value) as the STRIPE_SECRET_KEY environment variable when running pytest. This means your Django tests can call Stripe's API without touching real payment infrastructure.

Don't see STRIPE_SECRET_KEY_TEST in your secrets?

If you skipped adding this secret in Step 2, navigate to your GitHub repo, click Settings, then Secrets and variables > Actions, and click New repository secret. Name it STRIPE_SECRET_KEY_TEST and paste your sk_test_ key as the value.

Help me add Stripe test keys to my GitHub Secrets

Verify test mode with a test payment

The best way to confirm your preview deployment uses Stripe test mode is to make an actual test payment. Stripe provides a special card number that always succeeds in sandbox mode.

  • Open the Vercel preview URL from your most recent pull request. You can find this in the PR comments on GitHub where Vercel posted the preview link.
  • Navigate to any page in your app that triggers a Stripe payment flow (a checkout page, subscription form, or product purchase).
  • In the payment form, enter the following test card details:
  • Card number: 4242 4242 4242 4242
  • Expiry: any future date (e.g. 12/34).
  • CVC: any three digits (e.g. 123).
  • Other fields: any value you like.
  • Submit the payment.
  • You should see a success confirmation. No real money is charged because the frontend is using your pk_test_ publishable key.

How do I know this is test mode?

Stripe's test card 4242 4242 4242 4242 only works with test API keys. If your preview deployment were accidentally using live keys, this card number would be declined. A successful payment confirms your environment separation is correct.

Payment failing on the preview deployment?

Check that NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in Vercel's Preview environment starts with pk_test_. If it starts with pk_live_, the test card will not work.

Also confirm your Django backend's STRIPE_SECRET_KEY environment variable is set to an sk_test_ value in whatever environment serves the preview. If the backend secret is live but the frontend key is test, Stripe will reject the request.

Help me debug why my Stripe test payment is failing on my Vercel preview deployment

Document the environment variable mapping

With keys scattered across local files, Vercel settings, and GitHub Secrets, it is easy to lose track of what lives where. A mapping document makes this explicit for your team and your future self.

  • Create a file called ENV_MAPPING.md in your project root by running:
touch ENV_MAPPING.md
  • Open ENV_MAPPING.md in your editor and paste the following content:
# Environment Variable Mapping

## Dev (Local)

| Variable | Value | Location |
|----------|-------|----------|
| STRIPE_SECRET_KEY | sk_test_<your-key> | .env |
| STRIPE_PUBLISHABLE_KEY | pk_test_<your-key> | .env |
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | pk_test_<your-key> | .env.local |
| NEXT_PUBLIC_API_URL | http://localhost:8000 | .env.local |
| DATABASE_URL | <neon-dev-branch> | .env |

## Staging/Preview

| Variable | Value | Location |
|----------|-------|----------|
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | pk_test_<key> | Vercel Preview env |
| NEXT_PUBLIC_API_URL | <staging-django-url> | Vercel Preview env |
| STRIPE_SECRET_KEY_TEST | sk_test_<key> | GitHub Secrets |
| DATABASE_URL | <neon-pr-branch> | GitHub Secrets |

## Production

| Variable | Value | Location |
|----------|-------|----------|
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | pk_live_<key> | Vercel Production env |
| NEXT_PUBLIC_API_URL | <production-django-url> | Vercel Production env |
| STRIPE_SECRET_KEY | sk_live_<key> | ECS task definition |
| DATABASE_URL | <neon-production-branch> | ECS task definition |

What does this document do?

This is a living reference for your team. When someone asks "which Stripe key does staging use?", the answer is here. When you onboard a new developer, they can set up their local .env files by following the Dev section.

The key pattern to notice: dev and staging always use sk_test_ and pk_test_ keys. Production is the only environment that ever touches sk_live_ and pk_live_ keys.

  • Save ENV_MAPPING.md.
  • Confirm the file exists by running:
ls ENV_MAPPING.md

You should see ENV_MAPPING.md listed in the output.

File not showing up?

Make sure you created the file in your project root directory, not inside a subfolder. Run pwd to check your current location. The file should sit alongside your .github folder and .gitignore.

Help me find where I accidentally created my ENV_MAPPING.md file

Your Stripe environment separation is locked in. Every environment knows exactly which keys to use, and you have proof that test mode works on your preview deployments. Next up, you will containerize your Django backend with Docker to prepare it for production deployment on AWS.

Dockerize the Django Backend

Your CI pipeline tests code on every push. Your staging environment gives you isolated previews with their own database branches. The next piece of the puzzle is packaging your Django backend so it runs identically everywhere.

Docker solves this by wrapping your app, its dependencies, and its runtime into a single portable image. In this step, you'll create that image and verify it works locally before deploying it to AWS.

In this step, get ready to:
  • Create a Dockerfile that packages your Django app with Gunicorn.
  • Add a .dockerignore file to keep the image lean.
  • Build and run the container locally to verify it works.
Create the Dockerfile

A Dockerfile is a recipe that tells Docker how to build your application image. It specifies the base operating system, installs your Python dependencies, copies your code, and defines how to start the server.

  • Create a new file called Dockerfile in your Django project root (the same folder that contains manage.py).
  • Add the following content to your Dockerfile:
# Use a lightweight Python image
FROM python:3.12-slim

# Set the working directory inside the container
WORKDIR /app

# Copy requirements first to leverage Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application code
COPY . .

# Expose the port Gunicorn will listen on
EXPOSE 8000

# Start the Django app with Gunicorn
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a minimal Python 3.12 image. The slim variant keeps the image small by excluding build tools you don't need at runtime.
  • COPY requirements.txt . is copied separately before the rest of the code. This means Docker caches the installed packages and only re-installs them when requirements.txt changes.
  • CMD runs Gunicorn as the production WSGI server instead of Django's built-in runserver. Gunicorn handles multiple requests concurrently, which is essential for production traffic.

Why Gunicorn instead of runserver?

Django's built-in runserver command is designed for local development only. It processes one request at a time and has no security hardening.

Gunicorn is a production-grade WSGI server that spawns multiple worker processes to handle concurrent traffic. The --workers 2 flag tells it to run two processes, which is a good starting point for a single container.

  • Make sure gunicorn is listed in your requirements.txt file. If it's not already there, add it now.
  • Save Dockerfile.

Not sure where to create the Dockerfile?

The Dockerfile must live in the same directory as manage.py and requirements.txt. If your Django project is nested inside a subfolder of your monorepo, place it there.

Also confirm your config.wsgi:application path matches your actual project structure. If your Django settings module lives in a folder with a different name (e.g. myproject), update the path to myproject.wsgi:application.

help me figure out where my Dockerfile should go

Create the .dockerignore file

Just like .gitignore tells Git which files to skip, a .dockerignore tells Docker which files to exclude from the image. This keeps your image small and prevents sensitive files like .env from being baked into the image.

  • Create a new file called .dockerignore in the same directory as your Dockerfile.
  • Add the following content:
.env
.env.local
__pycache__
*.pyc
*.pyo
.git
.gitignore
node_modules
*.sqlite3
.DS_Store
venv
.venv

What does this exclude?

  • .env and .env.local keep secrets out of the image. You'll pass environment variables at runtime instead.
  • __pycache__, *.pyc, and *.pyo are compiled Python files that get regenerated inside the container anyway.
  • .git is your version history, which the container doesn't need.
  • venv and .venv are your local virtual environment. The container installs its own dependencies from requirements.txt.
  • Save .dockerignore.
Build and test the Docker image locally

With your Dockerfile and .dockerignore in place, you can now build the image and run it to confirm everything works before pushing to AWS.

  • Make sure Docker Desktop is running on your machine.
  • Build your Docker image by running this command from the directory containing your Dockerfile:
docker build -t django-backend .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t django-backend gives the image a name (tag) so you can reference it easily.
  • . tells Docker to use the current directory as the build context.

The build process may take a minute or two the first time as Docker downloads the python:3.12-slim base image and installs your dependencies.

  • Once the build completes, run the container by executing:
docker run -p 8000:8000 --env-file .env django-backend

What does this command do?

  • -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container, so you can access the app from your browser.
  • --env-file .env passes your local environment variables (database URL, Stripe keys, Django settings) into the container at runtime. This is why you excluded .env from the image itself.
  • Open your browser and navigate to http://localhost:8000.
  • Confirm your Django app responds. If you have a health check endpoint, hit that. Otherwise, confirm you see a Django response (even a 404 page from Django means the server is running correctly).

Container exits immediately or shows an error?

  • If you see a ModuleNotFoundError, a dependency is missing from requirements.txt. Make sure gunicorn and all other packages are listed.
  • If the container can't connect to the database, check that DATABASE_URL in your .env file uses a host accessible from inside the container. localhost inside a container refers to the container itself, not your machine. Your NeonDB URL should work fine since it's an external host.
  • If port 8000 is already in use, stop any other Django development server first with Ctrl+C in its terminal.

help me debug why my Docker container won't start

  • Once you've confirmed the app responds, stop the container by pressing Ctrl+C in the terminal where it's running.

✔️ Awesome, I've got everything!

Great. Double check you've saved both Dockerfile and .dockerignore.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]

And the complete .dockerignore:

.env
.env.local
__pycache__
*.pyc
*.pyo
.git
.gitignore
node_modules
*.sqlite3
.DS_Store
venv
.venv

Your Django app is now containerized and ready for cloud deployment. Next up, you'll push this image to Amazon ECR so AWS can access it.

Push to Amazon ECR

Your Django backend runs perfectly inside a Docker container on your local machine. The next challenge is making that image accessible to AWS so it can be deployed to a cloud server.

Amazon ECR (Elastic Container Registry) is AWS's managed Docker registry. It stores your Docker images in the cloud, right next to the compute services that will run them. In this step, you'll create an ECR repository, authenticate Docker with AWS, and push your image to the cloud.

In this step, get ready to:
  • Create a private ECR repository to store your Django Docker images.
  • Authenticate Docker with your ECR registry.
  • Tag and push your local Docker image to ECR.
Create an ECR repository in the AWS Console

Before you can push an image, you need a repository in ECR to receive it. Think of it like a folder specifically for your django-backend image.

  • Open the AWS Console in your browser and search for ECR in the top search bar.
  • Select Elastic Container Registry.
  • From the navigation bar, select the region us-east-1 (or your preferred region).
  • Choose Private repositories, then choose Create repository.
  • For Repository name, enter django-backend.
  • Leave all other settings at their defaults (Mutable tag, AES-256 encryption).
  • Click Create.

What is Amazon ECR?

Amazon ECR is a fully managed Docker container registry. It integrates directly with ECS, EKS, and other AWS compute services. Your images are stored securely and replicated across availability zones within the region.

ECR includes 500 MB of free private repository storage as part of the AWS Free Tier. A single Django image typically fits well within this.

After creation, you'll see your repository listed with a URI in the format:

<account-id>.dkr.ecr.us-east-1.amazonaws.com/django-backend
  • Copy this URI and save it somewhere. You'll need it for the next steps and for ECS configuration later.

Find your AWS Account ID

Your 12-digit AWS Account ID appears in the top-right corner of the AWS Console when you click your username. It's also the first part of your ECR repository URI.

Authenticate Docker with ECR

Docker needs permission to push images to your private ECR registry. AWS provides a temporary authentication token (valid for 12 hours) that you pipe directly into the docker login command.

  • Authenticate Docker with your ECR registry by running this command:
aws ecr get-login-password --region [[AWS_REGION="us-east-1"]] | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com

What does this command do?

  • aws ecr get-login-password retrieves a temporary authentication token from AWS.
  • The | pipes that token directly into the next command.
  • docker login --username AWS --password-stdin logs Docker into your private ECR registry using that token.
  • The token is valid for 12 hours. After that, you'll need to run this command again.

You should see a Login Succeeded message in your terminal.

Seeing an error?

  • If you see a credentials error, make sure your AWS CLI is configured with valid access keys. Run aws sts get-caller-identity to verify your credentials are working.
  • If Docker isn't running, start Docker Desktop first. The docker login command requires the Docker daemon to be active.
  • If the region doesn't match, double check that the region in your command matches the region where you created the ECR repository.

Help me troubleshoot my ECR authentication issue. I ran the get-login-password command and got an error.

Tag and push the image to ECR

Docker needs to know where to push your image. You tag your local image with the full ECR repository URI, then push it.

  • Tag your local django-backend image with your ECR repository URI by running:
docker tag django-backend:latest [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/django-backend:latest

What does tagging do?

Tagging doesn't create a copy of the image. It adds a new name (alias) that points to the same image data. The ECR-formatted tag tells Docker which remote registry and repository to push to.

  • Push the tagged image to ECR by running:
docker push [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/django-backend:latest

This uploads each layer of your Docker image to ECR. You'll see progress bars as each layer is pushed. The first push takes the longest since all layers are new.

  • Verify the image is in ECR by going back to the AWS Console.
  • Open the ECR service and select your django-backend repository.
  • Click on Images to view the image list.
  • You should see an image with the latest tag listed.

Push failed or timed out?

  • If you see a denied or authorization error, your login token may have expired. Re-run the authentication command from the previous substep.
  • If the push is very slow, check your internet upload speed. Django images are typically 200-400 MB.
  • If you see a repository not found error, make sure the repository name in your tag matches django-backend exactly.

Help me troubleshoot pushing my Docker image to ECR. I tagged it and ran docker push but got an error.

Your Docker image is now stored in the cloud. Next up, you'll deploy this image to a running service using Amazon ECS Express Mode.

Deploy Django to ECS Express Mode

Your Docker image is sitting in ECR, ready to run. Now you need somewhere to actually run it.

Amazon ECS Express Mode provisions a complete production stack from a single container image. It handles the load balancer, networking, auto-scaling, SSL certificate, and monitoring. You just point it at your ECR image and set your environment variables.

In this step, get ready to:
  • Create an ECS Express Mode service from your ECR image.
  • Configure production environment variables for the running container.
  • Verify your Django API is live and responding at its auto-generated URL.
Create an ECS Express Mode service in the AWS Console

ECS Express Mode creates a fully managed service from your container image. It provisions the cluster, load balancer, security groups, and auto-scaling policies in one step.

  • Open the AWS Console and search for ECS. Select **Elastic Container Service**.
  • In the left navigation pane, click Express mode.
  • Click Create.

You will see a streamlined form for configuring your service.

  • Under Image URI, click Browse ECR images.
  • For Private repository, select the django-backend repository.
  • For Image, select the image with the latest tag.
  • For Select image by, choose Image tag and confirm latest is selected.

Now configure the IAM roles.

  • For Task execution role, select ecsTaskExecutionRole from the dropdown. If it does not exist, select Create new role to have AWS create it automatically.
  • For Infrastructure role, select ecsInfrastructureRoleForExpressServices from the dropdown. If it does not exist, select Create new role.

What are these IAM roles?

The ecsTaskExecutionRole allows ECS to pull your container image from ECR and write logs to CloudWatch on your behalf.

The ecsInfrastructureRoleForExpressServices allows ECS to provision and manage the load balancer, security groups, SSL certificates, and auto-scaling policies that your service needs.

Next, expand the Additional configurations section to customize your service.

  • Click Additional configurations to expand the section.
  • For Service name, enter django-backend-service.
  • For Container port, change the value from 80 to 8000. This matches the port Gunicorn listens on in your Dockerfile.
  • For Health check path, enter /health/.

Why change the port to 8000?

Your Dockerfile tells Gunicorn to bind to port 8000. ECS Express Mode defaults to port 80, but the load balancer needs to route traffic to the port your app actually listens on. The health check path tells the load balancer which endpoint to ping to confirm your container is healthy.

Seeing an Invalid Parameter Exception?

The first time you create a service, you may see an error about being unable to assume the service linked role. Wait a few seconds and click Create again. AWS needs a moment to propagate the new roles.

help me fix this IAM role error when creating my ECS Express Mode service

Set production environment variables

Your Django app needs connection strings and secret keys to run in production. ECS Express Mode lets you set environment variables directly in the service configuration.

  • Still in the Additional configurations section, find Environment variables.
  • Click Add environment variable and add the following key-value pairs one at a time:
  • Key: DATABASE_URL with the value set to your NeonDB production branch connection string (from your Neon Console).
  • Key: STRIPE_SECRET_KEY with the value set to your Stripe live secret key (starts with sk_live_). Use a placeholder value like sk_live_your_key_here if you do not have a live key yet.
  • Key: SECRET_KEY with the value set to a strong random string for Django's secret key.
  • Key: DEBUG with the value set to False.
  • Key: ALLOWED_HOSTS with the value set to * for now. You will update this to your specific service URL once the deployment completes.

Where do I find my NeonDB production connection string?

Open the Neon Console, select your project, and make sure the production branch is selected. Copy the connection string from the Connection Details panel. It will look something like postgresql://user:password@ep-xxxx.us-east-1.aws.neon.tech/dbname.

Use Secrets Manager for production credentials

For a real production deployment, store sensitive values like DATABASE_URL and STRIPE_SECRET_KEY in AWS Secrets Manager instead of plain-text environment variables. ECS Express Mode supports referencing secrets using the valueFrom syntax. For this learning project, plain-text variables are fine.

  • After adding all five environment variables, click Create at the bottom of the page.

ECS will now provision your entire application stack. This typically takes 5-6 minutes.

What is ECS creating behind the scenes?

Express Mode provisions a complete stack: an ECS cluster (if one does not exist), a task definition with your container settings, an Application Load Balancer with HTTPS and an SSL certificate, security groups, auto-scaling policies, and a CloudWatch log group.

You get a unique URL in the format servicename.ecs.region.on.aws once the deployment finishes.

Verify the service is running

Once the deployment finishes, ECS provides an Application URL where your Django API is live.

  • Wait for the deployment status to show ACTIVE. You can monitor progress in the Resources tab of the console.
  • Once active, find the Application URL displayed in the console. It will look like django-backend-service.ecs.us-east-1.on.aws.
  • Copy the URL and open it in your browser, appending /health/ to the end.

You should see a 200 OK response from your Django health check endpoint. This confirms your container is running and receiving traffic through the load balancer.

  • Copy your ECS service URL and save it somewhere. You will need it for the CD pipeline in the next step.
  • Go back to your ALLOWED_HOSTS environment variable and update the value from * to your specific service URL (without https://).

Health check not returning 200?

Check that your Django app has a /health/ endpoint configured in your URL patterns. The endpoint must return a 200 response without requiring authentication.

Also verify that ALLOWED_HOSTS includes your service URL. If it is set too restrictively before deployment completes, Django will reject all requests with a 400 error.

help me debug why my Django ECS service health check is failing

Your Django backend is now live on AWS with automatic scaling, HTTPS, and a load balancer. Next up, you will automate the entire deployment so that merging to main pushes new code to production without any manual steps.

Build the CD Pipeline for Production

Your Django backend is live on ECS, but deploying updates still requires manually building images, pushing to ECR, and restarting the service. That repetitive process is exactly what a CD (Continuous Deployment) pipeline automates.

In this step, you'll create a GitHub Actions workflow that handles the full production deployment. Every time code merges to main, the workflow builds a fresh Docker image, pushes it to ECR, and tells ECS to pull the new version.

In this step, get ready to:
  • Create the CD workflow file that auto-deploys on merge to main.
  • Add AWS credentials and resource identifiers to GitHub.
  • Test the full pipeline by merging a pull request.
Create the CD workflow file

The CD workflow lives alongside your existing CI and Neon branching workflows in the .github/workflows directory. It triggers only on pushes to main, meaning every merged PR kicks off a production deployment automatically.

  • Create a new file called cd-production.yml inside your .github/workflows directory.
  • Add the full CD workflow by pasting the following code into the file:
name: CD - Production

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image to ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster ${{ vars.ECS_CLUSTER }} \
            --service ${{ vars.ECS_SERVICE }} \
            --force-new-deployment

What does this workflow do?

  • actions/checkout@v4 clones your repo so the workflow has access to your Dockerfile and source code.
  • aws-actions/configure-aws-credentials@v4 authenticates the workflow with your AWS account using the secrets you'll add next.
  • aws-actions/amazon-ecr-login@v2 logs Docker into your ECR registry so it can push images.
  • The build step creates two tags: the Git commit SHA (for traceability) and latest (for ECS to pull). Both get pushed to ECR.
  • aws ecs update-service --force-new-deployment tells ECS to pull the newest image and replace the running containers.
  • Save cd-production.yml.

Why two image tags?

Tagging with the Git SHA means every deployment is traceable back to the exact commit that produced it. The latest tag gives ECS a stable reference to always pull the most recent image.

Add AWS credentials and resource identifiers to GitHub

The workflow references secrets and variables that don't exist in your repository yet. You need to add your AWS credentials as encrypted secrets and your resource names as plaintext variables.

  • Open your GitHub repository in your browser.
  • Click Settings in the top navigation bar.
  • In the left sidebar, expand Secrets and variables, then click Actions.

You'll add two secrets first. These are encrypted and never exposed in logs.

  • Click New repository secret.
  • Enter AWS_ACCESS_KEY_ID as the name and paste your AWS access key ID as the value.
  • Click Add secret.
  • Click New repository secret again.
  • Enter AWS_SECRET_ACCESS_KEY as the name and paste your AWS secret access key as the value.
  • Click Add secret.

Now add the variables. These are plaintext values for resource identifiers that aren't sensitive.

  • Click the Variables tab (next to Secrets).
  • Click New repository variable.
  • Enter AWS_REGION as the name and us-east-1 as the value. Click Add variable.
  • Click New repository variable again.
  • Enter ECR_REPOSITORY as the name and django-backend as the value. Click Add variable.
  • Click New repository variable again.
  • Enter ECS_CLUSTER as the name and your ECS cluster name as the value. Click Add variable.
  • Click New repository variable one more time.
  • Enter ECS_SERVICE as the name and django-backend-service as the value. Click Add variable.

Why variables instead of secrets for resource names?

GitHub Variables are visible in workflow logs, which makes debugging easier. Secrets are masked in logs and meant for sensitive values like credentials. Resource names like your cluster and service aren't sensitive, so variables are the right fit.

  • Confirm you see both secrets (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) listed under the Secrets tab and all four variables (AWS_REGION, ECR_REPOSITORY, ECS_CLUSTER, ECS_SERVICE) under the Variables tab.

Don't see the Variables tab?

Make sure you're in Settings > Secrets and variables > Actions. The Variables tab sits right next to the Secrets tab at the top of the page.

help me find where to add GitHub Actions variables in my repository settings

Test the full pipeline

The CD workflow triggers on push to main. The simplest way to test it is to merge a pull request that includes your new workflow file.

  • Create a new branch and commit your cd-production.yml file by running these commands:
git checkout -b add-cd-pipeline
git add .github/workflows/cd-production.yml
git commit -m "Add CD pipeline for production deployment"
git push origin add-cd-pipeline
  • Open your GitHub repository in your browser.
  • Click Compare & pull request for your add-cd-pipeline branch.
  • Give the PR a title like Add CD pipeline and click Create pull request.
  • Wait for the CI checks to pass, then click Merge pull request.
  • Click Confirm merge.

The merge to main triggers your new CD workflow.

  • Click the Actions tab in your repository.
  • Click on the CD - Production workflow run to watch it execute.
  • Wait for all steps to show green checkmarks. This confirms the image was built, pushed to ECR, and ECS received the deployment command.

What should I see?

The workflow run shows each step completing in sequence: checkout, AWS credentials configuration, ECR login, image build and push, then ECS deployment. The entire run typically takes 2-5 minutes depending on your image size.

Workflow failing at the AWS credentials step?

Double-check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets are spelled exactly right (all caps, underscores). Also confirm the IAM user has permissions for ECR and ECS actions.

help me debug why my GitHub Actions workflow is failing at the AWS credentials configuration step

✔️ Awesome, I've got everything!

Great. Make sure you've saved cd-production.yml and confirmed the workflow ran successfully after merging.

ⓧ I'd like to double check the full code

Here's the complete cd-production.yml file for comparison:

name: CD - Production

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image to ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster ${{ vars.ECS_CLUSTER }} \
            --service ${{ vars.ECS_SERVICE }} \
            --force-new-deployment

Your production deployment is now fully automated. Next up, you'll test the entire flow end-to-end to make sure CI, staging, and production all work together seamlessly.

Test the Complete Pipeline End-to-End

You have all the pieces in place. A CI workflow that tests every push, a staging environment that spins up per PR, and a CD workflow that deploys to production on merge.

Now it's time to run through the entire developer workflow from start to finish. You'll simulate a real feature delivery: branch, code, push, review, merge, deploy.

In this step, get ready to:
  • Verify the full staging pipeline by pushing a feature branch.
  • Verify the full production pipeline by merging to main.
  • Confirm your code change is live on the production URL.
Create a feature branch and verify the staging pipeline

The staging pipeline involves three systems working together: GitHub Actions runs your tests, NeonDB creates an isolated database branch, and Vercel deploys a preview frontend. A single push to a feature branch should trigger all three.

  • In your terminal, create a new feature branch by running this command:
git checkout -b feature/test-pipeline
  • Make a small, visible code change to your Django project. For example, update the health check endpoint response or add a comment to a view file.
  • Stage, commit, and push your change by running these commands:
git add .
git commit -m "test: verify full pipeline end-to-end"
git push origin feature/test-pipeline
  • Open your GitHub repository in the browser.
  • Click Compare & pull request on the banner that appears for your new branch.
  • Add a title for your PR and click Create pull request.

Now watch the staging pipeline kick in. Three things should happen automatically.

  • Click the Actions tab in your GitHub repository.
  • Verify the CI workflow is running. Both the pytest and ruff checks should pass with green checkmarks.
  • Open the Neon Console and navigate to Branches. You should see a new branch with the preview/pr- prefix followed by your PR number.
  • Go back to your PR on GitHub. Vercel should post a comment with a preview URL. Click it to confirm the frontend loads correctly.

What should I see?

Your PR should show green CI checks, a Neon branch in the console, and a Vercel preview comment with a clickable URL. All three confirm that your staging pipeline is working end-to-end.

CI checks not appearing on the PR?

Make sure your ci.yml triggers on pull_request events, not just push. Also check that the workflow file is on the default branch (main) since GitHub requires workflow definitions to exist on the base branch.

help me troubleshoot why my CI checks aren't appearing on my pull request

Merge the PR and verify the production pipeline

With staging verified, merging to main should trigger the production pipeline. The CD workflow builds your Docker image, pushes it to ECR, and triggers an ECS redeployment. The Neon branching workflow also cleans up the staging database branch.

  • On your PR page in GitHub, click Merge pull request.
  • Click Confirm merge.
  • Navigate to the Actions tab. You should see the CD - Production workflow start running.
  • Click into the workflow run to watch the steps: AWS credential configuration, ECR login, Docker build and push, and ECS deployment.
  • Once the workflow completes with a green checkmark, open the Neon Console and check the Branches page again.
  • Verify that the preview/pr- branch from your PR has been automatically deleted.
  • Open the Amazon ECR console, navigate to your django-backend repository, and confirm a new image tag matching the merge commit SHA is listed alongside latest.

Why does ECS take a few minutes to update?

When you trigger --force-new-deployment, ECS performs a rolling update. It starts a new task with the updated image, waits for it to pass health checks, then drains the old task. This ensures zero downtime but means your change won't appear instantly.

CD workflow failed at the deploy step?

Check that your ECS_CLUSTER and ECS_SERVICE GitHub Variables match the exact names in your AWS Console. Even a trailing space will cause the update command to fail.

help me debug why my ECS deployment step failed in the CD workflow

Confirm the change is live on your production URL

The final check is confirming your code change is actually live. Your ECS service has a public URL that serves the Django backend.

  • Open the Amazon ECS console and navigate to your service.
  • Find the public URL for your service (shown in the service details or the load balancer configuration).
  • Open the URL in your browser and verify that the code change you made on the feature branch is now reflected in the production response.

What does a successful end-to-end test prove?

You just completed the full developer workflow: branch, push, test, preview, merge, deploy. Every step was automated. No manual SSH, no manual Docker builds, no manual database setup.

This is the same workflow used by production engineering teams. The only difference at larger scale is more test coverage and additional environments like QA or canary.

Change not showing on the production URL?

Wait 2-3 minutes for the ECS rolling update to complete. If the old response persists after that, check the ECS console to confirm the service's Running count matches the Desired count. If the new task is stuck in PENDING, the image may have failed its health check.

help me figure out why my ECS service is not showing the latest deployment

Your complete CI/CD pipeline is fully operational. Every push gets tested, every PR gets a staging environment, and every merge deploys to production automatically. Next up, the Secret Mission adds schema diff comments so your team can see exactly what database changes each PR introduces.

Secret mission

Add Schema Diff Comments to PRs

Want to give your team instant visibility into database changes? Add automatic schema diff comments to every pull request so reviewers can see exactly what tables, columns, or indexes were added, modified, or removed.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses AWS resources that incur ongoing costs when running. Vercel (Hobby plan) and NeonDB (free tier) do not cost anything to leave active.

Cost warning

AWS ECS Express Mode charges for underlying Fargate compute and ALB resources. With minimal traffic during learning, expect $1-3/day while the service is running. Amazon ECR provides 500 MB of free storage, so a single image incurs minimal cost.

If you are not actively using your deployment, choose Pause or Delete below to avoid charges.

Resources you used:

  • Amazon ECS Express Mode service (Django backend).
  • Amazon ECR repository (Docker images).
  • GitHub Actions workflows and repository secrets (AWS credentials, Neon API key).
  • Vercel project (Next.js frontend).
  • NeonDB database branches (staging branches created per PR).
  • Local Docker images and project files.

Keep everything running

No action needed. Choose this if you are still actively building on your pipeline or want a live production environment.

  • Your ECS service will continue running and serving traffic.
  • Vercel will continue building preview and production deployments on each push.
  • NeonDB branches will be created and cleaned up automatically by your workflow.
  • Be aware that the ECS service incurs $1-3/day in AWS charges for Fargate compute.

Pause - I'll come back to this later

Shut down the running ECS service to stop AWS charges, but keep your ECR images and GitHub workflows intact. You can redeploy anytime by pushing to main.

  • Open the Amazon ECS console at https://console.aws.amazon.com/ecs/v2.
  • Select the cluster running your Django service.
  • Choose the **Services** tab, select your service, and click **Delete**.
  • Select **Force delete service**, type delete in the confirmation prompt, and click **Delete**.
  • Your ECR images, Vercel project, NeonDB database, and GitHub workflows remain untouched for when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

Delete the ECS service
  • Open the Amazon ECS console at https://console.aws.amazon.com/ecs/v2.
  • Select the cluster running your Django service.
  • Choose the **Services** tab, select your service, and click **Delete**.
  • Select **Force delete service**, type delete in the confirmation prompt, and click **Delete**.
Delete the ECR repository
  • Delete your ECR repository and all its images by running:
aws ecr delete-repository --repository-name [[ECR_REPO_NAME="your-ecr-repository-name"]] --force
  • Alternatively, open the Amazon ECR console at https://console.aws.amazon.com/ecr/repositories, select the **Private** tab, choose your repository, and click **Delete**.
Remove GitHub Secrets
  • Go to your repository on GitHub.
  • Click **Settings**.
  • In the **Security** section of the sidebar, select **Secrets and variables**, then click **Actions**.
  • Click the **Secrets** tab.
  • Delete each secret you added for this project: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, NEON_API_KEY, and any ECR/ECS identifiers.
Delete the Vercel project
  • On the Vercel dashboard, select your project.
  • Open **Settings** in the sidebar.
  • Scroll to the bottom of the **General** page and click **Delete** in the Delete Project section.
  • Confirm by entering the project name and prompt, then click **Continue**.
Clean up NeonDB branches
  • Open the Neon Console and navigate to your project.
  • Delete any remaining preview/pr- branches that were not automatically cleaned up.
  • If you want to delete the entire Neon project, go to **Settings** and select **Delete Project**.
Remove local Docker images and project files
  • Remove your local Docker image by running:
docker rmi [[DOCKER_IMAGE_NAME="your-django-image-name"]]
  • Delete your local project folder by running:

macOS

rm -rf ~/Desktop/[[PROJECT_FOLDER="your-project-folder"]]

Windows

Remove-Item -Recurse -Force ~\Desktop\[[PROJECT_FOLDER="your-project-folder"]]

Nice Work!

Nice Work!

You just built a production-grade CI/CD pipeline from scratch. Your Django + Next.js application now flows from code change to live deployment without a single manual step.

Here's what you accomplished:

  • Built an automated CI pipeline with GitHub Actions that runs tests and linting on every push, blocking broken code from merging.
  • Created a complete staging environment with branch-per-PR database isolation using NeonDB branching and automatic preview deployments via Vercel.
  • Deployed a Dockerized Django backend to Amazon ECS Express Mode with a CD workflow that automatically pushes to production on merge to main.
  • Secret Mission: Added schema diff comments to pull requests so database changes are visible without manually checking migrations.

Ready to quiz yourself?