awslambdadockerkanikonextjsserverlessdreamer

Adding Serverless SSR to Dreamer: Two Silent Failures Between a Docker Build and a Public URL

SP

Saman Pandey

Jul 26, 2026 · 8 min read

Dreamer is the backend-heavy 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. All of that has worked cleanly for a while. What never worked was anything that needed a server, not a folder of files — a Next.js app using getServerSideProps or Server Components, the kind of thing Vercel itself handles by routing to a Lambda function per deployment.

So that became the next real feature: teach Dreamer to do the same thing. Not an always-on container behind a load balancer — an actual Lambda function per project, built via Kaniko (no Docker daemon on Fargate, so no docker build), pushed to ECR, and wired up to a public Function URL. Architecturally it came together fast. Getting it to actually respond to a browser took three separate debugging trails, and every one of them looked like a different kind of problem than it turned out to be.

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 go Active, and stands up a public Function URL.
  • reverse-proxy: routes {project}.mydomain to 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 — rebuild locally, and actually check 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 doesn't touch "$@" at all, so ls dockerfile-templates/ never ran; it got silently swallowed as an unused argument, and the container ran the entire build pipeline instead. The real check needed the entrypoint overridden 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 that was 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> is not 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.

nextjs-ssr and NEXT_SSR are, to a human, obviously the same framework. To the code, they're two completely different strings living 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 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 — created this year) supposedly default to it being on. The Lambda docs describe it clearly. The problem: 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:invokeFunction and lambda:invokeFunctionUrl permissions 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 every single deploy (idempotent — a ResourceConflictException on an already-granted statement is treated as success, not failure), so 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: 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 — instead of 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.