Adding Serverless SSR to Dreamer: Two Silent Failures Between a Docker Build and a Public URL
Saman Pandey
Jul 26, 2026 · 8 min read
Dreamer is the Vercel clone I've been building. GitHub import, framework detection, a real build pipeline on ECS Fargate, static output served straight from S3 through my own reverse proxy. The backend-heavy parts have worked cleanly for a while now. What never worked was anything that needed a server rather than a folder of files. A Next.js app using getServerSideProps or Server Components, the kind of thing Vercel handles by routing to a Lambda function per deployment.
So teaching Dreamer to do that became the next feature. Not an always-on container behind a load balancer. An actual Lambda function per project, built with Kaniko (there's no Docker daemon on Fargate, so no docker build), pushed to ECR, and wired to a public Function URL. The architecture came together fast. Getting it to actually respond to a browser took three separate debugging trails, and none of them was the kind of problem it first looked like.
Table of contents
- The Setup
- Problem 1: A File That Was Missing Exactly Where It Counted
- Problem 2: One String, Two Meanings
- Problem 3: A 403 That Survived Every Correct Answer
- Final Result
- Closing Thoughts
The Setup
build-engine(ECS Fargate): clones the repo, and for a DYNAMIC (SSR) project, resolves a Dockerfile, builds it with Kaniko, pushes the image to ECR.api-server: once the image lands, creates or updates a Lambda function pointing at it, waits for it to goActive, and stands up a public Function URL.reverse-proxy: routes{project}.mydomainto either an S3 path (static) or the deployment's Function URL (dynamic), depending on what the database says.
Clean on paper. The first real end-to-end test didn't even get past the build step.
Problem 1: A File That Was Missing Exactly Where It Counted
First sign of trouble, straight out of the Fargate task logs:
Fatal execution error: ENOENT: no such file or directory, open '/home/app/dockerfile-templates/nextjs-lambda.dockerfile'
Reasonable first guess: stale image. ECS was probably still running a build-engine build from before the Kaniko changes existed. So I rebuilt locally, and this time actually checked what's in the image before pushing it anywhere:
docker run --rm -it --env-file .env builder-image ls dockerfile-templates/
That printed Executing script.js and then the exact same fatal error, which looked like proof the folder genuinely wasn't there. It wasn't proof of that yet, though. The image's ENTRYPOINT is ["/home/app/main.sh"], and main.sh is just exec node script.js. It never touches "$@", so ls dockerfile-templates/ never ran; the command got silently swallowed as an unused argument, and the container ran the entire build pipeline instead. To actually check, I had to override the entrypoint outright:
docker run --rm -it --entrypoint sh --env-file .env builder-image -c "ls dockerfile-templates/"
ls: dockerfile-templates/: No such file or directory
That was the real answer. Not a stale image. The folder genuinely didn't exist in the build context on disk, because it hadn't made it into the local working copy in the first place. No COPY bug, no .dockerignore exclusion, nothing subtle. Just a file missing before the build ever started. Copying it in properly and rebuilding fixed it outright.
The lesson buried in there isn't really about Docker. It's that docker run <image> <command> isn't a safe way to inspect a container's filesystem unless you already know the entrypoint won't eat your command. --entrypoint sh -c "..." is the version that actually does what it looks like it does.
Problem 2: One String, Two Meanings
With the folder actually present, the same manual test produced a new, much more specific error:
Fatal execution error: No Dockerfile was found in the repository, and "nextjs-ssr" has no
generated-Dockerfile template yet (only NEXT_SSR does). Add a Dockerfile to the repository
root, or deploy a Next.js SSR project instead.
To a human, nextjs-ssr and NEXT_SSR are obviously the same framework. To the code, they're two completely different strings that live in two different places for two different reasons. nextjs-ssr is the human-facing preset id the dashboard shows in a dropdown. NEXT_SSR is the internal enum value the database and the Dockerfile-template lookup actually key off. The real deploy pipeline only ever sends the enum value. The mismatch only existed in a local .env file I used for manually testing the container outside of ECS, where it's natural to type the name you actually see in the UI. Setting FRAMEWORK=NEXT_SSR locally instead of the preset id closed this one out immediately.
Two failures in, and neither one was actually about AWS, Docker, or Kaniko doing anything wrong. Both were about assuming two things were the same because they looked the same.
Problem 3: A 403 That Survived Every Correct Answer
With the build fixed, an actual deploy went all the way through. Image built, pushed to ECR, Lambda function created, Function URL issued, deployment marked live. Then the browser:
{"Message":"Forbidden. For troubleshooting Function URL authorization issues, see: https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html"}
This is the one that actually took real digging, because every obvious cause checked out clean, one at a time.
AuthType? Checked directly in the console. NONE, correct.
Resource policy?
aws lambda get-policy --function-name dreamer-portfolio --query Policy --output text
{"Version":"2012-10-17","Id":"default","Statement":[{"Sid":"PublicFunctionUrlInvoke","Effect":"Allow","Principal":"*","Action":"lambda:InvokeFunctionUrl","Resource":"arn:aws:lambda:ap-south-1:...:function:dreamer-portfolio","Condition":{"StringEquals":{"lambda:FunctionUrlAuthType":"NONE"}}}]}
Also correct. Principal: "*", the right action, the right condition. By every rule I knew of, this should have worked.
Was it actually Lambda, or something in front of it? Bypassed reverse-proxy and the browser entirely and hit the raw Function URL directly:
curl -v https://qky575abvs3abub5bpu5gbk26i0niufm.lambda-url.ap-south-1.on.aws/
Same 403, verbatim. That ruled out the proxy layer completely. This was Lambda itself rejecting the request before it ever reached my code.
At that point I chased a real but ultimately wrong lead. AWS did add an account-level "block public access" setting for Lambda Function URLs in 2024, and new AWS accounts (mine included, since it was created this year) supposedly default to it being on. The Lambda docs describe it clearly. The problem was that the actual JS SDK I had installed, a genuinely current version, had no matching API operation anywhere in it. No PutPublicAccessBlockConfig, nothing close. Rather than hand over a CLI command built entirely from documentation I couldn't cross-check against what was actually installed, the better move was checking the one place that doesn't depend on any SDK or CLI version at all. The Lambda console itself, on the same Permissions tab the policy statement lives on.
And there it was. Not a "block public access" toggle, but a plain diagnostic banner Lambda had been showing the entire time:
Your function URL auth type is NONE, but is missing permissions required for public access. To allow unauthenticated requests, choose the Permissions tab and create a resource-based policy that grants
lambda:invokeFunctionandlambda:invokeFunctionUrlpermissions to all principals (*).
Two actions. Not one. The policy already had lambda:InvokeFunctionUrl, the piece that felt like the obviously relevant one, since it's literally the Function URL invoke permission. But AWS also requires the plain lambda:InvokeFunction alongside it, and AddPermission only accepts a single Action per call. There's no way to grant both from one statement; it has to be two separate calls with two separate statement IDs.
aws lambda add-permission \
--function-name dreamer-portfolio \
--statement-id PublicInvokeFunction \
--action lambda:InvokeFunction \
--principal "*" \
--function-url-auth-type NONE
Ran, and the URL resolved immediately. Full page, correctly server-rendered.
The permanent fix went into the actual deploy code, not just the CLI. Instead of one AddPermission call, it now loops over both required statements on every deploy. It's idempotent, so a ResourceConflictException on an already-granted statement is treated as success rather than failure. That way this can't quietly resurface on a redeploy or a fresh project the way it originally did.
Final Result
https://portfolio.singularitydev.xyz
A real, server-rendered Next.js app, deployed through Dreamer's own dashboard, built with zero Docker daemon involved, running on a Lambda function Dreamer created for it automatically. Same shape as visiting an app on Vercel, minus the parts of Vercel I haven't built yet.
Closing Thoughts
None of these three were the "real" bug I expected going in. I was ready to debug IAM policies, Kaniko flags, Lambda cold starts, the parts that felt like the hard, interesting engineering. What actually broke were three much quieter things: a file absent from a local folder, one string doing double duty as two different identifiers, and a permission model that needs two grants where one looks sufficient.
The pattern across all three, if there is one, is that every failure produced a message that sounded like it was describing its own root cause, and wasn't. "No such file" sounded like a path bug, not a missing copy. "No template for nextjs-ssr" sounded like an unsupported framework, not a naming mismatch. "Forbidden" sounded like a wrong policy, not an incomplete one. Every fix came from refusing to stop at the first plausible-sounding explanation and instead going and checking the actual state directly. The real filesystem inside the container. The real policy document. The console's own diagnostic banner. Not reasoning forward from what the error message implied.
Nothing here was actually about Lambda, or Docker, or Next.js being difficult. It was about three different systems each being very precise about what they needed, and the error messages only getting you partway to what that precision actually was.