reverse-proxy
reverse-proxy is the single hostname every deployed app is actually
reached through. It's a small, standalone Express service — no
api-server, no dashboard, no auth — with exactly one job: given a
request for {something}.yourdomain.com, figure out what that
subdomain is currently serving, and proxy the request there.
Why a dedicated service, not part of api-server
Every request to every deployed app — including apps that have nothing
to do with the Dreamer dashboard itself — passes through this service.
Folding it into api-server would mean a traffic spike on one deployed
app competes for the same process, the same connection pool, the same
event loop as the actual control plane. Keeping it separate means a
deployed app misbehaving (or just being popular) can't degrade the
dashboard, and vice versa.
It's also, deliberately, the one service in this system simple enough to run outside the "normal" application stack entirely — see the Self-Hosting Guide, where it's just another container behind nginx, with its own narrow Postgres/Redis dependency and nothing else.
Request handling, step by step
app.use(async (req, res) => {
const hostname = req.hostname; // "myapp.yourdomain.com"
const subdomain = hostname.split('.')[0]; // "myapp"
const route = await resolveRoute(subdomain);
if (!route) {
res.status(404).send('No deployment found for this subdomain');
return;
}
if (route.type === 'DYNAMIC') {
proxy.web(req, res, { target: route.lambdaFunctionUrl, changeOrigin: true });
return;
}
const resolvesTo = `${BASE_PATH}/${subdomain}`;
proxy.web(req, res, { target: resolvesTo, changeOrigin: true });
});
One handler, one branch. Everything else in this service exists to make
resolveRoute() correct and fast.
Step 1: the subdomain IS the lookup key
req.hostname.split('.')[0] — no path parsing, no header inspection
beyond what Express already gives you. This works because the subdomain
is, by construction, exactly Project.slug (see
projects docs)
— there's no separate mapping table to consult, the hostname the visitor
typed already IS the identifier.
Step 2: resolveRoute — the one query this whole service exists to run
SELECT d.type AS "type", d."s3Prefix" AS "s3Prefix", d."lambdaFunctionUrl" AS "lambdaFunctionUrl"
FROM "Project" p
JOIN "Deployment" d ON d.id = p."activeDeploymentId"
WHERE p.slug = $1 AND p."deletedAt" IS NULL
Two things worth noticing about this query:
- It joins through
Project.activeDeploymentId, not "the newest deployment for this project." A project can have manyDeploymentrows (every build ever run), but only one is ever "live" at a time — this is the same fieldstopDeployment()and the redeploy flow both read and write (see deployments overview). A deployment that failed, or one that was superseded by a later redeploy, is simply never reachable through this join, with zero extra filtering logic needed here. deletedAt IS NULL— a soft-deleted project (see projects docs) stops resolving immediately, even though its row (and itsDeploymenthistory) still physically exists in the database.
A missing row (no project with that slug, or a project with no active deployment at all) and a project that's plainly gone are indistinguishable from this query's result — both just come back empty, and both correctly produce a 404. This service has no reason to know or care why nothing matched.
Why this query goes straight to pg, not Prisma
reverse-proxy uses a plain pg.Pool, not the generated Prisma client
api-server uses everywhere else. This is a deliberate exception, not
an oversight: this is the one query in the entire system that runs on
every single request to every deployed app — pulling in the full
Prisma client (its generated types, its query engine) for one read-only
query is a materially heavier dependency than this narrow, latency-
sensitive path needs.
The 30-second cache
const cacheKey = `${CACHE_KEY_PREFIX}${subdomain}`;
const cached = await cache.get(cacheKey);
if (cached !== null) {
return cached === 'null' ? null : JSON.parse(cached);
}
const result = await pool.query(/* ... */);
const route = result.rows[0] ?? null;
await cache.set(cacheKey, route ? JSON.stringify(route) : 'null', 'EX', 30);
return route;
Without this, every request to every deployed app would cost a
Postgres round trip before it could even start proxying — a tax the
service pays on the hot path of every single visitor to every single
deployed project. 30 seconds is short enough that a newly-RUNNING
deployment starts resolving correctly well within any reasonable "just
deployed, why isn't it live yet" wait, with zero separate
cache-invalidation plumbing needed back in api-server — the cache just
naturally expires and re-queries.
Misses are cached too — the literal string 'null' (disambiguated
from a JSON-serialized actual null value, which would also stringify
to 'null', by the ternary checking the ORIGINAL route value, not the
cached string, before deciding how to interpret what came back). A
typo'd or long-deleted subdomain doesn't get a fresh Postgres query on
every single request either — it gets the same 30-second cache treatment
as a hit.
Step 3: the two very different proxy targets
STATIC — proxies to an S3 HTTPS URL built from the subdomain
directly (${BASE_PATH}/${subdomain}, where BASE_PATH already
includes the __outputs prefix every build uploaded under). See
static deployments for the
/ → /index.html rewrite this path also needs, scoped specifically to
STATIC requests via a req.dreamerRouteType flag set before the
proxy.web() call — applying that rewrite to a DYNAMIC request would
corrupt the path before it ever reaches the app's own router.
DYNAMIC — proxies straight to the deployment's own Lambda Function
URL, with changeOrigin: true (the opposite of what you'd want for a
shared, host-header-routed front door — see
dynamic deployments
for exactly why). No S3-specific rewrite applies here at all — Lambda's
own HTTP-facing layer (via the Lambda Web Adapter inside the function)
handles routing the request to the app's own server unmodified.
Self-hosted topology: nginx in front, reverse-proxy behind it
When self-hosting the whole platform (see the
Self-Hosting Guide), reverse-proxy itself never
touches the public internet directly — nginx terminates TLS for the
wildcard domain and forwards to reverse-proxy over the private compose
network, preserving the original Host header so resolveRoute() still
sees the real subdomain the visitor typed:
server {
listen 443 ssl;
server_name *.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
location / {
proxy_pass http://reverse-proxy:9000;
proxy_set_header Host $host;
# ...
}
}
This is the same reason a wildcard TLS certificate has to cover both
the bare apex domain (the dashboard) AND *.yourdomain.com (every
deployed app) as SANs in one certificate — see the Self-Hosting Guide's
certificate section for the full reasoning.
Going deeper on wildcard TLS specifically
If you're setting up a custom domain and hit anything unexpected around
certificates — a wildcard that isn't actually covered, a PaaS custom-
domain flow that doesn't behave the way you expected, or you're
wondering how any of this differs on a self-hosted VPS versus a managed
host — see Wildcard Domains, which covers
the two genuinely different kinds of wildcard ( *.domain.com vs.
*.sub.domain.com ) and walks through a real production setup for both.