Static Deployments, From Scratch
This is the original, simplest deployment path in Dreamer — a repo that
produces a folder of static files (HTML/CSS/JS, no server process
required to serve it). Covers everything from the moment build-engine
picks up the job to a visitor's browser actually receiving those files.
If you're trying to understand this system by building your own version from nothing, this is the doc to start with — dynamic (SSR) deployments build on every concept introduced here.
Why S3, and why one prefix per project
Static output is just files — no compute needed to serve them, which
makes an object store the obvious fit: durable, cheap, and — critically
for this platform — it means "serving a deployed app" never touches
api-server's own compute or database connections at all. A spike in
traffic to one deployed app can't degrade the platform's own dashboard.
Every file a build produces is uploaded under one key prefix, keyed by the project's slug, not the deployment's:
__outputs/{project.slug}/{relative file path}
This is the same "one live thing per project" rule covered in the deployments overview — every deployment of the same project overwrites the same prefix. There's no versioning, no "deployment #12's files" living alongside "deployment #13's files" — the newest successful build simply replaces what was there.
The build, step by step
This is build-engine's runStaticBuild(), run inside a single ECS
Fargate task (see architecture overview
for why a dedicated, ephemeral task and not a shared process):
1. npm install (or pnpm/yarn/bun — see framework-detection docs)
2. npm run build
3. Verify the configured OUTPUT_DIRECTORY actually exists on disk
4. Walk every file in it, upload each one to S3
5. Publish RUNNING with the public URL + file count
Step 3: verifying the output directory exists
const distFolderPath = path.join(buildContextPath, OUTPUT_DIRECTORY);
if (!fs.existsSync(distFolderPath)) {
const notFoundError = new Error(
`Build finished but expected output directory "${OUTPUT_DIRECTORY}" was not found at ${distFolderPath} — ` +
`check that the project's Output Directory setting matches what "${BUILD_COMMAND}" actually produces`
);
notFoundError.step = 'build';
throw notFoundError;
}
This one check is what turns "the build silently succeeded but there's
nothing to serve" (a genuinely confusing failure mode — everything LOOKS
fine, npm run build exited 0) into a specific, actionable error message
in the build log, pointing directly at the mismatch between what the
framework preset assumed and what actually got built.
Step 4: the upload loop
const distFolderContents = fs.readdirSync(distFolderPath, { recursive: true });
for (const file of distFolderContents) {
const filePath = path.join(distFolderPath, file);
if (fs.lstatSync(filePath).isDirectory()) continue;
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: `__outputs/${PROJECT_SLUG}/${file}`,
Body: fs.createReadStream(filePath),
ContentType: mime.lookup(filePath) || 'application/octet-stream',
});
await s3Client.send(command);
}
Two details that matter:
ContentTypeis set explicitly, per file, via themime-typespackage inferring from the file extension. Without this, S3 serves every object with a genericapplication/octet-streamcontent type by default — browsers won't render an.htmlfile as HTML or execute a.jsfile as a script without the correct header telling them to.- Streamed, not buffered —
fs.createReadStream(filePath)as theBody, not a fullreadFileSync. A build with large assets (video, large images, source maps) doesn't need to hold the whole file in memory before it can start uploading.
Step 5: what "RUNNING" actually means for STATIC
const url = `https://${PROJECT_SLUG}.${BASE_DOMAIN}`;
publishStatus('RUNNING', { url, uploadedFileCount: uploadedCount });
There's no separate "starting" phase the way DYNAMIC has (see that doc)
— the moment the last file lands in S3, the deployment IS live; S3
doesn't need to "start up." uploadedFileCount is purely informational,
surfaced in the dashboard's build summary.
Serving: how reverse-proxy turns a subdomain into an S3 fetch
See reverse-proxy docs for the full routing picture across both deployment types. The STATIC-specific piece:
const resolvesTo = `${BASE_PATH}/${subdomain}`;
proxy.web(req, res, { target: resolvesTo, changeOrigin: true });
BASE_PATH is the S3 bucket's HTTPS endpoint plus the __outputs
prefix — subdomain (which is exactly project.slug, by construction)
completes the same key prefix every upload wrote to.
One deliberate quirk: the / → /index.html rewrite.
proxy.on('proxyReq', (proxyReq, req, _res) => {
if (req.dreamerRouteType === 'STATIC' && req.url === '/') {
proxyReq.path += 'index.html';
}
});
S3 is a plain object store — there is no object literally named "" at
a bucket root, so a request for / has nothing to match unless
something rewrites it to the actual object that exists,
__outputs/{slug}/index.html. This rewrite is scoped to STATIC requests
only (req.dreamerRouteType, set earlier in the request handler) —
applying it to a DYNAMIC request would corrupt the path before it ever
reaches a Lambda-hosted server's own router.
This is also, not coincidentally, why a single-page app's client-side
router works at all through this proxy for the root path but a deep
link (/dashboard/settings on first load, not a client-side
navigation) does NOT automatically work — this rewrite only covers the
exact bare / path, not every unmatched route. A production version of
this platform would extend this to a proper SPA fallback (any 404 from
S3 retried against index.html); as shipped, it covers the common case
(the root path) and no more.
Teardown
Two places delete an S3 prefix, both calling the same
deleteS3Prefix(prefix):
- Stopping a
RUNNINGstatic deployment (only if it's the project's currentactiveDeploymentId— see deployments overview). - Deleting a project entirely — see projects docs. Best-effort: an S3 failure here logs an error but never blocks the delete itself.
Without this cleanup, a different, future project that happens to land on the same slug (see the collision handling in projects docs) would silently inherit a deleted project's stale files until its own first successful deploy overwrites them.