A complete, ordered walkthrough for deploying NextStep to AWS from a clean account. Follow the sections in order, each one only depends on resources created in an earlier section, nothing later in this guide requires going back to change something earlier.
Written for an instructor with AWS Console experience. Steps are detailed enough to follow without needing outside documentation, but this assumes familiarity with what a VPC, security group, or task definition fundamentally is.
- Tear down existing resources (if redoing a previous attempt)
- Naming conventions and values you'll collect along the way
- VPC and networking
- Security groups (all three, in dependency order)
- Secrets Manager: application secrets
- RDS: PostgreSQL database
- S3: résumé bucket and frontend bucket
- SES: email sending identity
- ECR: push the backend image
- IAM: execution role and task role
- Application Load Balancer and target group
- ECS: cluster, task definition, and service
- Verify the backend independently
- Frontend: build and upload to S3
- CloudFront distribution
- KMS (encryption notes)
- Full end-to-end verification
- Teardown checklist (for next time)
If you deployed a previous attempt and want a clean slate, delete in this order, each one before the one above it in the AWS resource dependency chain, otherwise deletion will fail with a "resource in use" error:
- ECS: delete the service first (scale to 0, then delete), then the cluster
- Application Load Balancer: delete the load balancer, then the target group (deleting the ALB does not automatically delete its target group)
- CloudFront: disable the distribution, wait for it to finish disabling (can take several minutes), then delete it
- S3: empty both buckets (frontend and résumés), then delete the buckets
- RDS: delete the database instance (skip the final snapshot if this is genuinely disposable test data), this also deletes its managed secret automatically
- ECR: delete the repository (and its images)
- Secrets Manager: delete the
nextstep/app-secretssecret (this one has a default 7-30 day recovery window, use "force delete without recovery" if you want it gone immediately and plan to recreate it with the same name) - IAM: delete the two roles (execution role, task role) and any inline policies on them
- Security groups: delete all three (db, ecs, alb), in that order, security groups referencing each other block deletion until the referencing rule is gone, deleting the one nothing else depends on first (db-sg) avoids that
- VPC: delete the VPC last, this cleans up subnets, route tables, the internet gateway, and the NAT gateway together (NAT gateways can take a few minutes to fully delete, and are billed until they're gone, don't skip this one)
Using consistent names throughout avoids confusion later. This guide uses:
| Resource | Name |
|---|---|
| VPC | nextstep-vpc |
| Security groups | nextstep-alb-sg, nextstep-ecs-sg, nextstep-db-sg |
| RDS instance | nextstep-db |
| RDS database name | nextstep |
| RDS master username | nextstep_admin |
| App secrets | nextstep/app-secrets |
| Résumé bucket | nextstep-resumes-<unique-suffix> |
| Frontend bucket | nextstep-frontend-<unique-suffix> |
| ECR repository | nextstep-backend |
| ECS cluster | nextstep-cluster |
| ECS task family | nextstep-backend-task |
| ECS service | nextstep-backend-service |
| ALB | nextstep-alb |
| Target group | nextstep-tg |
| IAM roles | nextstep-ecs-execution-role, nextstep-ecs-task-role |
Adjust to your own naming scheme if you prefer, but pick one before starting and stay consistent, most of the copy-paste mistakes in a first deployment come from typos between similarly-named resources.
You'll be collecting these actual values as you go, worth keeping a scratch note open:
- VPC ID
- Public subnet IDs (x2), private subnet IDs (x2)
-
nextstep-alb-sgID -
nextstep-ecs-sgID -
nextstep-db-sgID -
nextstep/app-secretsARN - RDS endpoint hostname
- RDS managed secret ARN (auto-created, holds only username and password)
- Résumé bucket name
- Frontend bucket name
- SES verified sender address
- ECR repository URI
- Execution role ARN, task role ARN
- ALB DNS name, target group ARN
- CloudFront domain name
- VPC Console > Create VPC
- Resources to create: VPC and more
- Name tag:
nextstep-vpc - Availability Zones: 2
- Number of public subnets: 2
- Number of private subnets: 2
- NAT gateways: 1 (in 1 AZ), a single shared NAT gateway, not one per AZ
- VPC endpoints: none, leave default (S3 gateway endpoint is optional here, skip it for this pass, it's a later optimization, not required to function)
- Create VPC. This auto-creates route tables, an internet gateway attached to the public subnets, and a NAT gateway route for both private subnets, no manual route table editing needed
- Once created, note the VPC ID and all four subnet IDs (VPC Console > Subnets, filter by this VPC, the tags show which are public vs. private)
RDS and the ECS service go in the private subnets. The ALB goes in the public subnets. Nothing else in this guide touches VPC configuration again.
Creating these now, together, in the order below, means each one already exists by the time the next one needs to reference it. This is the single biggest source of back-and-forth in a first attempt, doing this section out of order or scattered across later steps is exactly what caused it last time.
- EC2 Console > Security Groups > Create security group
- Name:
nextstep-alb-sg, VPC:nextstep-vpc - Inbound rules:
- Type: HTTP, Port 80, Source:
0.0.0.0/0 - Type: HTTPS, Port 443, Source:
0.0.0.0/0
- Type: HTTP, Port 80, Source:
- Outbound rules: leave the default (all traffic allowed)
- Create
- Create security group
- Name:
nextstep-ecs-sg, VPC:nextstep-vpc - Inbound rules:
- Type: Custom TCP, Port 3000 (or whatever port the backend container
actually listens on, confirm against
Backend/Dockerfile'sEXPOSEline), Source: security groupnextstep-alb-sg
- Type: Custom TCP, Port 3000 (or whatever port the backend container
actually listens on, confirm against
- Outbound rules: leave the default (all traffic allowed, this is what lets ECS tasks reach RDS, S3, SES, ECR, and Secrets Manager through the NAT gateway)
- Create
- Create security group
- Name:
nextstep-db-sg, VPC:nextstep-vpc - Inbound rules:
- Type: PostgreSQL, Port 5432, Source: security group
nextstep-ecs-sg
- Type: PostgreSQL, Port 5432, Source: security group
- Outbound rules: leave the default
- Create
All three exist now, fully wired, before anything that needs them (RDS, the ALB, the ECS service) gets created. Nothing later in this guide requires editing a security group rule.
Creating this now, early, means it's ready by the time the IAM roles and task definition need to reference its ARN.
- Secrets Manager Console > Store a new secret
- Secret type: Other type of secret
- Key/value pairs:
JWT_SECRET: generate withopenssl rand -base64 32(run this locally, paste the output in)ADMIN_EMAIL: the email address the instructor will use to log into the admin accountADMIN_PASSWORD: a real password, not a placeholder, this becomes the actual admin login
- Secret name:
nextstep/app-secrets - Create, note the ARN shown on the secret's detail page
This secret does not include database credentials, RDS manages its own separately in the next section.
- RDS Console > Create database
- Engine: PostgreSQL. Templates: Free tier or Dev/Test
- DB instance identifier:
nextstep-db - Credentials management: select Manage master credentials in AWS
Secrets Manager. Set a master username:
nextstep_admin(this username itself is not sensitive and will be entered as a plain value later, only the password is stored as a secret) - Instance class: smallest available (
db.t3.micro/db.t4g.micro) - Storage: default
- Connectivity:
- VPC:
nextstep-vpc - Subnet group: create new, restricted to the two private subnets
- Public access: No
- VPC security group: choose existing, select
nextstep-db-sg(already created in step 3c, do not create a new one here)
- VPC:
- Additional configuration: initial database name:
nextstep - Create database. Provisioning takes several minutes
- Once available, RDS Console >
nextstep-db> Connectivity & security tab, note:- The endpoint (hostname)
- The port (5432 by default)
- The master credentials ARN (under Configuration tab), this is the
secret RDS auto-created, note it, it holds only
usernameandpasswordas JSON keys, nothing else
No manual step needed here. The backend applies Backend/sql/schema.sql and
Backend/sql/seed_jobs.sql itself at startup, idempotently, before it starts
accepting requests, this runs identically whether it's talking to the local
Docker Postgres container or a real RDS instance, since it's the
application doing it, not something depending on Postgres's
docker-entrypoint-initdb.d mechanism (which only exists for locally-run
Postgres containers and has no RDS equivalent).
The first time the ECS service starts against this fresh RDS database in step 11, the tables and seed jobs get created automatically as part of that startup. Nothing to run separately, and nothing to remember to do before moving on to step 6.
- S3 Console > Create bucket
- Name:
nextstep-resumes-<unique-suffix> - Block all public access: keep enabled
- Default encryption: enable, SSE-KMS, AWS-managed key (
aws/s3) is fine for now - Create
- Required: Permissions tab > Cross-origin resource sharing (CORS) >
Edit. The frontend uploads résumés directly to this bucket from the
browser using a presigned URL, so without CORS configured, the upload
fails with a browser CORS error, this step is easy to miss since the
bucket otherwise appears to work fine (downloads, console access) without
it, the failure only shows up when a real browser upload is attempted.
Add:
The CloudFront domain isn't known yet at this point in the guide (it's created in step 14), come back and fill in the real value once it exists, or set
[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET"], "AllowedOrigins": ["https://<cloudfront-domain>"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ]AllowedOriginsto["*"]temporarily to unblock testing and tighten it to the real domain once known, don't leave it as"*"permanently
- Create bucket
- Name:
nextstep-frontend-<unique-suffix> - Block all public access: keep enabled (CloudFront will access it via Origin Access Control, set up in step 14, not through public access)
- Default encryption: same as above
- Create
Note both bucket names, needed later for the IAM policy, the ECS task definition, and the frontend upload/CloudFront steps.
-
SES Console > Verified identities > Create identity
-
Choose Domain (recommended) or Email address for a quicker setup
-
Domain: add the DKIM CNAME records shown to your DNS provider, wait for verification (up to 72 hours, usually faster). Email address: click the verification link sent to that address
-
Note the verified sender address or domain, this is
SES_SENDER_EMAIL -
SES Console > Account dashboard: check sending status. If still in sandbox mode (the default for a new account), two separate things are restricted, not just one:
- Only verified identities can be used as a sender
- Only verified identities can receive mail at all, sandbox mode rejects sending to any unverified recipient, regardless of who the sender is
This means testing the actual interview-invite / rejection email flow end to end requires verifying a second identity, a real inbox you have access to, register a test candidate account using that same verified address, and admin-sent emails to it will actually arrive. This isn't a separate configuration step, it's the same "Create identity" flow above, run a second time for a different email address
Request production access here (Account dashboard) if this deployment needs to email real, unverified candidate addresses without each one being manually verified first (approval typically takes a few hours to two business days)
- ECR Console > Create repository, name:
nextstep-backend, private - Use the repository's "View push commands" button, run the shown
docker login,docker build,docker tag,docker pushcommands locally againstBackend/Dockerfile - Confirm the image appears with a tag (e.g.
latest) - Note the full image URI shown in the repository
Both roles are created now, before the task definition in step 11, since the task definition needs to reference both by ARN, and both roles need the resource ARNs collected in earlier steps (S3 bucket names, the two secret ARNs) to write their policies correctly.
Used by ECS itself to pull the image and inject secrets at container start, not used by your application code.
- IAM Console > Roles > Create role
- Trusted entity type: AWS service > Elastic Container Service > Elastic Container Service Task
- Attach AWS-managed policy:
AmazonECSTaskExecutionRolePolicy - Add an inline policy for reading both secrets:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": [ "<RDS-managed secret ARN from step 5>", "<nextstep/app-secrets ARN from step 4>" ] } ] } - Name:
nextstep-ecs-execution-role, create, note the ARN
Used by your running application code, S3, SES, and any direct secret reads performed at runtime.
- Create role, same trusted entity setup as above
- Skip attaching any AWS-managed policy, add a custom inline policy:
The
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ResumeBucketAccess", "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "arn:aws:s3:::nextstep-resumes-<suffix>/*" }, { "Sid": "SendEmail", "Effect": "Allow", "Action": ["ses:SendEmail", "ses:SendRawEmail"], "Resource": "*" }, { "Sid": "SecretsRead", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": [ "<RDS-managed secret ARN from step 5>", "<nextstep/app-secrets ARN from step 4>" ] } ] }s3:GetObject/PutObjectactions are scoped to.../*(objects inside the bucket), not the bucket ARN itself, those are different resources in IAM. SES doesn't support restricting to one identity as simply as S3 does,Resource: "*"here is normal - Name:
nextstep-ecs-task-role, create, note the ARN
Creating the ALB and its target group here, as their own standalone step, before the ECS service exists, avoids the awkward "create it inline during service creation, then go back and fix the subnets" problem from a previous attempt.
- EC2 Console > Target Groups > Create target group
- Target type: IP (required for Fargate)
- Name:
nextstep-tg - Protocol: HTTP, Port: 3000 (must match the container's actual listening port)
- VPC:
nextstep-vpc - Health check path: a real backend route that returns a success status,
e.g.
/api/jobs - Create. Don't register any targets manually, the ECS service will do that automatically in step 11
- EC2 Console > Load Balancers > Create load balancer > Application Load Balancer
- Name:
nextstep-alb - Scheme: internet-facing
- VPC:
nextstep-vpc. Mappings: select the two public subnets only, do not include the private subnets - Security group: remove the default, attach
nextstep-alb-sg(created in step 3a) - Listener: HTTP, port 80, default action: forward to
nextstep-tg. Add an HTTPS listener on 443 later once an ACM certificate exists, HTTP-only is fine to get the full stack working first - Create, note the ALB's DNS name once provisioned
- ECS Console > Clusters > Create cluster
- Name:
nextstep-cluster, infrastructure: AWS Fargate - Create
- ECS Console > Task definitions > Create new task definition
- Family:
nextstep-backend-task - Launch type: AWS Fargate
- Task size: 0.5 vCPU / 1 GB memory
- Task role:
nextstep-ecs-task-role(from step 9) - Execution role:
nextstep-ecs-execution-role(from step 9) - Container definition:
-
Image URI: the ECR image from step 8
-
Port mappings: container port 3000
-
Environment variables, plain values:
Key Value AWS_REGIONyour region, e.g. us-east-2S3_BUCKET_NAMEthe résumé bucket name from step 6 SES_SENDER_EMAILthe verified sender from step 7 DB_HOSTthe RDS endpoint hostname from step 5 DB_PORT5432DB_NAMEnextstepDB_USERnextstep_adminDB_SSLtrue -
Environment variables, ValueFrom (Secrets Manager):
Key ValueFrom DB_PASSWORD<RDS-managed secret ARN>:password::JWT_SECRET<nextstep/app-secrets ARN>:JWT_SECRET::ADMIN_EMAIL<nextstep/app-secrets ARN>:ADMIN_EMAIL::ADMIN_PASSWORD<nextstep/app-secrets ARN>:ADMIN_PASSWORD::The RDS-managed secret only contains
usernameandpassword, that's whyDB_USERabove is a plain value, not a secret reference, and why there's noDB_NAMEsecret reference either, it was never in there. EachValueFromentry needs the JSON key placed directly after the secret's ARN with a single colon, then a trailing::, in that exact order, this is the most common typo in this step, double check each one
-
- Create the task definition
nextstep-cluster> Create service- Launch type: Fargate, task definition:
nextstep-backend-task - Service name:
nextstep-backend-service - Desired tasks: 1
- Networking: VPC
nextstep-vpc, subnets: the two private subnets, security group:nextstep-ecs-sg. Public IP: not required - Load balancing: use an existing load balancer, select
nextstep-alb, existing listener (port 80), existing target group:nextstep-tg. Do not create a new load balancer here, it was already created in step 10 - Create service
Before touching the frontend at all, confirm the backend, database, and load balancer path work on their own:
- ECS Console >
nextstep-cluster> Tasks tab, confirm a task is running (not cycling/restarting) - EC2 Console > Target Groups >
nextstep-tg> Health checks, confirm the target shows healthy (may take a minute or two after the service starts) - Visit
http://<alb-dns-name>/api/jobsdirectly in a browser. This should return JSON (a job list, possibly empty if seed data hasn't run yet), not an error, not a timeout, not a 404
If the target is unhealthy or this request fails, check task logs
(CloudWatch Logs, linked from the task detail page in ECS Console) before
proceeding, common causes at this stage: the nextstep-ecs-sg inbound rule
from nextstep-alb-sg is missing (health checks never reach the container),
or a database connection error appears in the logs (check DB_SSL is set,
and that the ValueFrom ARNs are formatted correctly)
Do not move on to the frontend until this step returns real JSON.
The built static assets are already committed in the repo at
Frontend/static/ -- no build step needed here. Wait until the ALB (or a
custom domain) address is known and confirmed working (step 12) before
uploading, since the runtime backend URL is set via config.js (see below),
not baked in at build time.
- Upload the contents of
Frontend/static/to the frontend bucket from step 6:or via S3 Console > Uploadaws s3 sync Frontend/static/ s3://nextstep-frontend-<suffix>/
Frontend/public/config.js ships with an empty default and gets copied into
the build output automatically:
window.APP_CONFIG = {
API_URL: ""
};To change the backend URL after deployment without rebuilding, edit this
one file directly (locally then re-upload, or edit it in the S3 console)
with the full API URL including /api, then invalidate it in
CloudFront (step 14). Leaving it empty falls through to the build-time
VITE_API_URL value used above.
- CloudFront Console > Create distribution
- Origin: the frontend S3 bucket
- Origin access: Origin Access Control, CloudFront generates a bucket policy statement, apply it to the bucket when prompted (S3 Console > frontend bucket > Permissions > Bucket policy > paste it in), this is a required manual step, don't skip it, a missing bucket policy is the most common cause of a 403 when loading the site afterward
- Default root object:
index.html - Viewer protocol policy: redirect HTTP to HTTPS
- Custom error responses: map both 403 and 404 to
/index.htmlwith a 200 status, needed for client-side routing to survive a refresh or direct link - Custom domain: optional, needs an ACM certificate requested in
us-east-1specifically - Create, note the CloudFront domain name
Any time static content changes after the first deploy:
- CloudFront Console > the distribution > Invalidations tab > Create invalidation
- Object paths:
/config.jsfor just that file, or/*for a full redeploy - Create, completes within a minute or two
Nothing to do here by default, encryption has already been happening the
whole time. S3 got SSE-KMS enabled with the AWS-managed default key
(aws/s3) back in step 6, and RDS storage encryption was left on at
creation in step 5. This section isn't a required step in the encryption
path, it's placed here only because it's the natural point to mention the
optional next level: swapping those AWS-managed default keys for a
dedicated customer-managed key, which gives more control (key rotation
policy, more granular access permissions, ability to revoke) than the
defaults do.
To do that:
- KMS Console > Create key > Symmetric, Encrypt and decrypt
- Alias:
nextstep-key - Key administrators/usage permissions: include the ECS task role and any IAM users/roles needing direct access
- Reference this key's ARN when configuring encryption on RDS and the S3 buckets instead of the AWS-managed defaults, this means going back and changing the encryption key setting on those resources, since it can't be changed on RDS after creation without a snapshot/restore, this is really a "decide before step 5" choice if you want a customer-managed key from the start, rather than something to bolt on after the fact
- Visit the CloudFront domain, confirm the frontend loads
- Register a candidate account, confirm the request in the browser's
Network tab hits
.../api/auth/...on the ALB successfully - Log in as admin (
ADMIN_EMAIL/ADMIN_PASSWORDfromnextstep/app-secrets) - Post a job, apply as the candidate with a résumé upload, confirm the file lands in the résumé S3 bucket
- Send an interview invite and a rejection from the admin view, confirm SES delivers to a verified recipient address (or any address, if production access was granted)
Same order as section 0, kept here for quick reference once you're actually done with a given deployment rather than mid-troubleshooting:
ECS service and cluster, ALB and target group, CloudFront distribution
(disable first), both S3 buckets (empty then delete), RDS instance, ECR
repository, nextstep/app-secrets secret, both IAM roles, all three
security groups, then the VPC last.
RDS, the ALB, and the NAT gateway are the three resources that bill continuously while they exist, even completely idle, those are the ones worth deleting promptly between demos if this isn't staying up permanently.