AWS Console Setup Guide — Static + Dynamic (SSR) Deployments
This is the complete AWS setup for Dreamer, in the order you'd actually need it: Part 1 gets STATIC deployments working — the foundation everything else sits on top of (the IAM user every service authenticates as, the S3 bucket, the ECS cluster that runs every build). Part 2 adds DYNAMIC (Next.js SSR / Lambda) support on top of that. If you're starting this project from nothing, do Part 1 first, confirm a static site actually deploys end to end, then move on to Part 2.
This guide covers AWS resource provisioning only. For actually running
api-server/frontend/reverse-proxy somewhere and pointing your domain
at them, see SELF-HOSTING.md — that's a separate
concern from the AWS side covered here, and the two combine (self-hosted
control plane + AWS-hosted build/deploy compute is the normal, expected
setup, not a mismatch).
Part 1 — STATIC deployments
Prerequisites
- An AWS account.
- A domain with wildcard DNS support (
*.yourdomain.com) — seeSELF-HOSTING.mdfor the DNS side once you get there; this section only covers the AWS resources themselves.
Step 1 — Create the IAM user
This is the one identity every service in this project authenticates
as — api-server uses its access key directly for every AWS API call it
makes, and also forwards that same key into every build-engine
container it launches (see deployment-engine.ts's launchBuildTask —
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are passed as container
overrides). There's no separate ECS task role anywhere in this project —
one user, one set of keys, used everywhere.
- Console → IAM → Users → Create user.
- Name it something recognizable, e.g.
dreamer-app. - Don't attach any permissions yet — you'll come back and add a scoped policy in Step 7, once the resources it needs to reference actually exist.
- After creating the user: Security credentials tab → Create
access key → Application running outside AWS → create. Copy
both the Access key ID and Secret access key immediately — the
secret is shown exactly once. These become your
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY.
Step 2 — Create the S3 bucket
This is where every STATIC build's output actually lives.
- Console → S3 → Create bucket.
- Setup all env vars: S3_BUCKET=your-bucket-name, AWS_REGION=your-region, BASE_PATH=https://bucket-name.s3.region.amazonaws.com/__outputs
- Uncheck "Block all public access" (all four checkboxes) — every deployed static app needs its files publicly readable; there's no CloudFront, no signed URLs, no auth in front of this bucket in this project's design. Acknowledge the warning.
- Create the bucket, then go to its Permissions tab → Bucket
policy → paste:
Scoped to the{ "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadForDeployedApps", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/__outputs/*" } ] }__outputs/*prefix specifically — not the whole bucket — so only actual deployed-app files are public, not anything else you might later store in this same bucket.
Step 3 — VPC networking for ECS
Every build runs as an ECS Fargate task, which needs at least one subnet and one security group to launch into.
Simplest path: use your account's Default VPC — every AWS account
has one already, with a subnet in every Availability Zone, which is
enough to get started. Console → VPC → Subnets — copy the
Subnet ID (looks like subnet-0a1b2c3d4e5f6g7h8) for two or three of
them, from the default VPC.
Then create a security group for the build tasks: VPC → Security
Groups → Create security group, in that same default VPC. No
inbound rules needed (a build task doesn't accept incoming connections —
it clones a repo and pushes output out, it never listens on anything);
leave the default outbound: allow all rule, which build tasks need
to reach GitHub, npm's registry, and AWS's own APIs. Copy the Security
group ID (sg-0a1b2c3d4e5f6g7h8).
Important naming gotcha, worth reading before you fill in env vars
later: this project's env vars are named ECS_SUBNET1_ARN,
ECS_SUBNET2_ARN, ECS_SUBNET3_ARN, and ECS_SECURITY_GROUP_ARN — but
despite the _ARN suffix, AWS's own RunTask API does not accept
ARNs for awsvpcConfiguration.subnets/securityGroups — it wants the
plain IDs (subnet-..., sg-...), exactly as copied above. Put the
ID in, not the ARN, regardless of what the variable name suggests — this
is a naming inconsistency in the codebase, not a hint about what format
is expected.
Step 4 — Create the ECS cluster
- Console → ECS → Clusters → Create cluster.
- Name it (e.g.
dreamer-cluster), infrastructure: AWS Fargate (serverless) — no EC2 instances to manage. - Create. Copy the cluster's ARN from its detail page — this is
your
ECS_CLUSTER_ARN(the one place in this whole setup where the_ARN-named variable actually does want a real ARN —clusteraccepts either the ARN or the bare name, so either works, but the ARN is unambiguous).
Step 5 — Push the build-engine image somewhere ECS can pull it from
This is a different image/repo from anything DYNAMIC-related in Part 2 — this is the platform's own build-runner image, not a deployed app's image.
- Console → ECR → Create repository → private, name it
dreamer-build-engine(or similar). - From
apps/build-enginelocally: copy push commands from AWS console ECR repo dashboardaws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-south-1.amazonaws.com docker build -t dreamer-build-engine . docker tag dreamer-build-engine:latest <account-id>.dkr.ecr.ap-south-1.amazonaws.com/dreamer-build-engine:latest docker push <account-id>.dkr.ecr.ap-south-1.amazonaws.com/dreamer-build-engine:latest
Step 6 — Create the ECS Task Definition
- Console → ECS → Task definitions → Create new task definition.
- Launch type: AWS Fargate. OS/architecture: Linux/X86_64 (or ARM64, if you built the image for that — matches whatever your Docker build's host architecture was).
- Task size: 1 vCPU / 3GB memory is a reasonable starting point for a
typical
npm install && npm run build— bump this later if you see builds getting OOM-killed on larger projects. - Task execution role: let the console create a new one for you
(
ecsTaskExecutionRole) — this only needs permission to pull the image and write CloudWatch logs, both of which the defaultAmazonECSTaskExecutionRolePolicyalready covers. No task role needed beyond this — build-engine authenticates to AWS via the static keys forwarded into its environment (Step 1), not an IAM role. - Container details:
- Name: this exact string becomes your
TASK_DEFINITION_IMAGE_NAMEenv var —deployment-engine.tsuses it to target the right container when applying overrides, so whatever you type here has to match exactly.build-imageis a reasonable choice. - Image URI: the ECR image URI from Step 5.
- Leave port mappings empty — this container never listens on anything.
- Name: this exact string becomes your
- Create. Copy the task definition's ARN — this is your
ECS_TASK_DEFINITION_ARN.
Step 7 — Attach a permissions policy to the IAM user from Step 1
Now that the bucket and cluster exist, go back to IAM → Users → your user → Add permissions → Create inline policy → JSON:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StaticOutputBucket",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::dreamer-outputs",
"arn:aws:s3:::dreamer-outputs/*"
]
},
{
"Sid": "RunAndStopBuildTasks",
"Effect": "Allow",
"Action": ["ecs:RunTask", "ecs:StopTask", "ecs:DescribeTasks"],
"Resource": "*"
},
{
"Sid": "PassBuildTaskExecutionRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole"
}
]
}
iam:PassRole here is the same category of easy-to-miss requirement as
the one in Part 2's IAM setup — RunTask passes the task execution role
along with it, and the caller needs explicit permission to hand that
role out, or the call fails with an AccessDenied mentioning
iam:PassRole specifically.
Step 8 — Set the environment variables
On api-server (.env, or wherever you're setting it for your hosting
setup — see SELF-HOSTING.md):
AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=<from Step 1>
AWS_SECRET_ACCESS_KEY=<from Step 1>
S3_BUCKET=dreamer-outputs
ECS_CLUSTER_ARN=<from Step 4>
ECS_TASK_DEFINITION_ARN=<from Step 6>
ECS_SUBNET1_ARN=<subnet ID from Step 3 — an ID, not an ARN, see the gotcha above>
ECS_SUBNET2_ARN=<a second subnet ID>
ECS_SUBNET3_ARN=<a third subnet ID, or repeat one of the above if your default VPC only has two AZs>
ECS_SECURITY_GROUP_ARN=<security group ID from Step 3 — again, an ID>
TASK_DEFINITION_IMAGE_NAME=<the exact container name from Step 6>
Step 9 — Test end to end
- Deploy
api-server,frontend, andreverse-proxysomewhere they can reach each other and the internet (seeSELF-HOSTING.mdfor the one-command path). - Point your domain's DNS at wherever
reverse-proxyis reachable (again, covered in the Self-Hosting Guide). - In the dashboard, import a plain static repo — a Vite or Create React App project is the simplest thing to test with first, before attempting anything SSR-related in Part 2.
- Deploy it. Watch the build logs: clone → install → build → upload
to S3 →
RUNNING. - Visit
https://{project-slug}.yourdomain.com— you should see the actual app. - Check the S3 console —
dreamer-outputs→__outputs/{project-slug}/should have every file the build produced.
Once this works, move on to Part 2 for SSR support.
Part 2 — DYNAMIC (SSR) deployments
Everything below builds on Part 1 — the IAM user, the ECS cluster, and
the build-engine task definition all already exist by this point;
this section only adds what SSR support needs on top of that.
Read this first, it saves you a step: your project uses that same one
long-lived IAM user's access key from Part 1
(AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY in api-server's env) for
everything — api-server's own AWS calls, AND build-engine's S3/ECR
work (api-server forwards its own keys into the build task as container
overrides — see deployment-engine.ts's launchBuildTask). There are no
ECS task IAM roles involved anywhere in this project. So: you do not
need to touch ECS task roles again here. You need to (1) extend that
same IAM user's permissions further, and (2) create one new, separate
IAM role — but that new role is for Lambda functions to assume at
runtime, which is a completely different thing from the IAM user your
services authenticate AS. Don't conflate the two; Step 2 and Step 3 below
are deliberately kept apart for this reason.
Also good news: Lambda Function URLs need no VPC, no security group, no subnet configuration at all. Unlike Part 1's ECS setup, these Lambda functions are not attached to your VPC (they don't need to reach anything inside it), so there's no networking section in this half of the guide.
Step 1 — Create the ECR repository
This is where Kaniko pushes every DYNAMIC app's image.
- Console → ECR → Repositories → Create repository.
- Visibility: Private.
- Repository name:
dreamer-dynamic-apps. - Image scan settings → turn on Scan on push (free, catches known CVEs in the base image / dependencies).
- Leave encryption/tag immutability at defaults.
- Create. Copy the URI shown on the repository's page — it looks like
123456789012.dkr.ecr.ap-south-1.amazonaws.com/dreamer-dynamic-apps. This is yourECR_REPOSITORY_URI.
(Optional, worth doing later, not blocking: Repository → Lifecycle policy → add a rule expiring untagged images after 7 days. Every redeploy pushes a new digest under the same tag; the old digest becomes untagged and just sits there costing storage until something cleans it up.)
Step 2 — Create the Lambda execution role
This is the role every deployed app's Lambda function assumes when it runs — separate from your own IAM user.
- Console → IAM → Roles → Create role.
- Trusted entity type: AWS service.
- Use case: Lambda. Next.
- Attach permissions policy: search for and check AWSLambdaBasicExecutionRole (an AWS-managed policy — grants only CloudWatch Logs write access). Don't attach anything broader than this — this project has no way to know what other AWS services a user's deployed app might try to call, so it isn't granted access to anything beyond logging its own output.
- Role name:
dreamer-lambda-execution-role. Create. - Open the role, copy its ARN (top of the page, looks like
arn:aws:iam::123456789012:role/dreamer-lambda-execution-role). This is yourLAMBDA_EXECUTION_ROLE_ARN.
Step 3 — Extend your existing IAM user's permissions
Find the IAM user whose access key is already sitting in your
api-server env as AWS_ACCESS_KEY_ID (check IAM → Users if you don't
remember the name — it's the one with your existing ECS/S3 policies
attached).
- Console → IAM → Users → click that user.
- Permissions tab → Add permissions → Create inline policy.
- Switch to the JSON editor and paste:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PushToDynamicAppsEcrRepo",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
},
{
"Sid": "PushToDynamicAppsEcrRepoScoped",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage"
],
"Resource": "arn:aws:ecr:YOUR_REGION:YOUR_ACCOUNT_ID:repository/dreamer-dynamic-apps"
},
{
"Sid": "ManageDynamicAppLambdaFunctions",
"Effect": "Allow",
"Action": [
"lambda:GetFunction",
"lambda:CreateFunction",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:DeleteFunction",
"lambda:GetFunctionUrlConfig",
"lambda:CreateFunctionUrlConfig",
"lambda:DeleteFunctionUrlConfig",
"lambda:AddPermission",
"lambda:RemovePermission"
],
"Resource": "arn:aws:lambda:YOUR_REGION:YOUR_ACCOUNT_ID:function:dreamer-*"
},
{
"Sid": "PassLambdaExecutionRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/dreamer-lambda-execution-role"
}
]
}
Replace YOUR_REGION and YOUR_ACCOUNT_ID (found top-right of the console,
or via aws sts get-caller-identity).
Two things worth understanding, not just pasting:
dreamer-*in the Lambda resource ARN matcheslambdaFunctionNameFor()indeployment-engine.ts(dreamer-${projectSlug}) — scoping the policy to this prefix means this IAM user can't touch any other Lambda function in your account, only ones this platform itself created.iam:PassRoleis easy to forget and produces a confusing error if you do:CreateFunctionCommandpassesLAMBDA_EXECUTION_ROLE_ARNas the function's execution role, and AWS requires whoever callsCreateFunctionto also have explicit permission to "pass" that role along — otherwise you could get one IAM identity to hand out a more privileged role than it holds itself. Without this statement, function creation fails with anAccessDeniedmentioningiam:PassRolespecifically, which doesn't obviously point back to "add this statement" unless you already know to look for it.
- Name the policy
dreamer-dynamic-app-permissions. Create.
Step 4 — Set the new environment variables
On wherever api-server actually runs (your .env file, or your
hosting platform's env var settings):
ECR_REPOSITORY_URI=123456789012.dkr.ecr.ap-south-1.amazonaws.com/dreamer-dynamic-apps
LAMBDA_EXECUTION_ROLE_ARN=arn:aws:iam::123456789012:role/dreamer-lambda-execution-role
LAMBDA_ARCHITECTURE=x86_64
Check LAMBDA_ARCHITECTURE against your existing setup before assuming
x86_64 is right: Kaniko builds the image using whatever CPU
architecture the build-engine Fargate task itself runs on. Open ECS →
your cluster → Task Definitions → your build-engine task definition →
check Runtime platform → CPU architecture. If it says ARM64
(Graviton), set LAMBDA_ARCHITECTURE=arm64 instead — a mismatch here
means Lambda rejects the image at CreateFunction with an architecture
error, not a subtle runtime bug, so you'll know immediately if this is
wrong, but better to check now.
reverse-proxy (wherever it's hosted — its own box, per your README)
needs, in its own .env:
DATABASE_URL=<the same Postgres connection string api-server uses>
REDIS_URL=<the same Redis instance build-engine/api-server use>
This service had zero database dependency before this change — if it's currently firewalled off from your database (e.g. RDS security group only allows the VPC), you'll need to open that up, since reverse-proxy runs outside the VPC per your own README's self-hosting section.
Step 5 — Database migration
From your local machine (or wherever you run migrations), pointed at your
real DATABASE_URL:
cd apps/api-server
npx prisma migrate deploy
npx prisma generate
This applies 20260716120000_add_lambda_dynamic_runtime (adds
lambdaFunctionArn/lambdaFunctionName/lambdaFunctionUrl to
Deployment) and regenerates the TypeScript client properly — the copy
shipped in the zip was hand-patched in a sandbox that couldn't reach
Prisma's binary download servers; this command replaces it with the real
thing, safely.
Step 6 — Rebuild and push the build-engine image
The Kaniko binary is baked into build-engine's own Docker image at
build time now (Dockerfile's new COPY --from=gcr.io/kaniko-project/executor:debug
line). The image your ECS task definition currently references does
not have this yet — you need to rebuild and push a new one.
- From
apps/build-engine:
(This is whatever ECR repo/registry your build-engine task definition already points at — not the newdocker build -t build-engine . docker tag build-engine:latest <your-existing-build-engine-ecr-repo-uri>:latest docker push <your-existing-build-engine-ecr-repo-uri>:latestdreamer-dynamic-appsrepo from Step 1, which is for deployed apps' images, not this platform's own build-engine image.) - If your ECS task definition pins an image digest rather than
:latest, register a new task definition revision pointing at the freshly-pushed image, and update your ECS service (if build-engine runs as a service) or just let the nextRunTaskcall pick up the new:latesttag (if, like the STATIC path today, it's launched fresh per build with no persistent service).
Step 7 — Deploy the updated api-server code
Standard deploy for however you already ship api-server — the important
part is that it's running the code from this zip (with npm install run
to pick up the new @aws-sdk/client-lambda dependency) and has the Step 4
env vars set before it starts.
Step 8 — Deploy reverse-proxy's updated code
Same idea — npm install (for the new pg/ioredis dependencies), the
Step 4 env vars set, then restart it.
Step 9 — Test end to end
- Pick (or create) a small Next.js repo with
output: 'standalone'set innext.config.js:
If you don't have one handy,/** @type {import('next').NextConfig} */ module.exports = { output: 'standalone' }npx create-next-app@latestand add that one line before pushing it to a repo Dreamer can access. - Import it as a new project in Dreamer. The wizard should now detect it
as Next.js with no "unsupported runtime" warning (that flag was
flipped off in
framework-presets.tsas part of this change). - Deploy. Watch the build logs — you should see, in order: clone →
commit info →
"No Dockerfile found — generating one from the NEXT_SSR template."→ Kaniko's own build/push output →"Image pushed to ECR"→ then a status flip to STARTING (this is where api-server is callingCreateFunctionand waiting for it to go Active — expect 10–30 seconds here) → RUNNING. - Verify in the AWS Console while it's building, to build a mental
model of what's actually happening: ECR →
dreamer-dynamic-appsrepo → you should see a new tag matching your project's slug appear right after the "Image pushed to ECR" log line. Lambda → Functions → you should seedreamer-{your-project-slug}appear once STARTING begins. - Visit
https://{your-project-slug}.{your-base-domain}— this request goes throughreverse-proxy's new DB lookup, resolves toDYNAMIC, and proxies to the Function URL. You should see your actual Next.js app, server-rendered. - Try Stop on the deployment from the dashboard — confirm in the Lambda console that the function and its Function URL are gone afterward.
- Push a small change to the repo and redeploy — confirm (a) the SAME Lambda function's code gets updated (not a second function created), and (b) the Function URL stays the same as before.
Troubleshooting
Build fails at "Kaniko exited with code 1", stderr mentions the ECR
push specifically (not the image build itself) → almost always the IAM
policy in Step 3 — check the Resource ARN on the ECR statements matches
your actual repo ARN exactly (region, account ID, repo name).
Function created successfully, but visiting the URL gives a 403 → the
AddPermission call in deployDynamicApp() didn't run or was denied.
Check the IAM policy includes lambda:AddPermission. You can verify
manually: Lambda console → your function → Configuration →
Permissions → scroll to Resource-based policy statements — you
should see one named PublicFunctionUrlInvoke.
Build fails with a Docker COPY .next/standalone: not found error →
the target repo's next.config.js is missing output: 'standalone'. You
should also see a WARN-level log line earlier in the build calling this
out before the failure — that's dockerfile-resolver.js's best-effort
check catching it (see the code comments on why that check warns instead
of hard-blocking).
CreateFunction fails with an AccessDenied mentioning iam:PassRole
→ see the callout in Step 3 — you're missing the PassLambdaExecutionRole
statement.
CreateFunction fails with an architecture/image mismatch error →
see the LAMBDA_ARCHITECTURE callout in Step 4.
reverse-proxy returns 404 for a deployment you can see is RUNNING in
the dashboard → check reverse-proxy can actually reach your database
(Step 4's networking note) and that its Redis cache isn't serving a stale
cached-miss from before the deployment went live — this self-heals within
30 seconds either way (CACHE_TTL_SECONDS in deployment-lookup.js), so
if it's been longer than that and still 404ing, it's the DB connection.