← All posts

Monorepos on a git-push Host: Serving Frontend and API Under One Domain

October 29, 2026

TL;DR: Deploy the frontend and the API as two apps from the same repository, give each its own root directory, and put your domain on the frontend only. Then have the frontend’s server forward /api/* to the API over the private network, so the browser only ever talks to one origin and CORS never enters the picture. This works on Candor Deploy today and on any host where your apps can reach each other privately.

A monorepo with web/ and api/ folders is one of the most common shapes for a side project or a small product. It’s also where many first deploys go sideways: the build runs in the wrong folder, the frontend calls localhost:4000 in production, or the browser blocks every request with a CORS error. This guide walks through a layout that avoids all three.

What does “one domain” actually buy you?

When the browser loads https://example.com and then calls https://example.com/api/orders, both requests share an origin. That has practical benefits:

  • No CORS configuration. Same-origin requests don’t trigger preflight checks, so there’s no allow-list of origins to maintain per environment.
  • Simpler cookies. A session cookie set by the API on example.com is sent on every same-origin request without SameSite=None workarounds or cross-site cookie rules.
  • One certificate and one DNS record to think about. Your visitors and your frontend code only need one hostname.
  • Relative URLs in the frontend. Your code calls /api/orders everywhere, in development and production, instead of switching base URLs per environment.

The alternative, a separate api.example.com, is perfectly workable. It just moves complexity into CORS headers, cookie domains, and per-environment configuration. If you don’t need a public API for third parties, one domain is usually the simpler starting point.

How should the repository be laid out?

Keep each deployable in its own directory with everything it needs to build:

my-product/
  web/          # frontend (package.json, framework config)
  api/          # backend (package.json, pyproject.toml, go.mod, ...)
  .candorfile   # optional, at the repo root

The rule that matters: each app’s stack-identifying file (package.json, pyproject.toml, go.mod, Gemfile, composer.json) must sit at the root of that app’s directory. A build that starts at the repository root of a monorepo often finds nothing it recognizes, or finds the wrong thing.

On Candor Deploy, when it detects a repository’s apps for you (see the next section), detection looks for deployables at the repo root, in top-level directories, and one level deep under apps/, packages/ and services/, and the builder is decided inside each app’s directory (builds and configuration docs). So web/ and api/, or apps/web/ and apps/api/, are found without restructuring.

Shared code, such as a packages/types folder used by both apps, deserves a moment of thought. If an app’s build only sees its own directory, imports that reach outside it (../../packages/types) won’t resolve, and neither will a workspace dependency such as "@acme/types": "workspace:*", because workspace packages are resolved from the repository root. Publish the shared package to a registry so it installs like any other dependency, or keep shared code small enough to copy into each app. Test this by building each app from a fresh clone, starting inside its own folder.

How do you deploy two apps from one repo?

Treat the frontend and the API as two services that share a repository and a branch. Each gets its own root directory, its own environment variables, and its own logs.

On Candor Deploy, the most direct route today is the Candor MCP (connect Claude). Its deploy_from_repo tool previews the repository, deploys every app it detects together in one new project, and wires them to each other (more on that below). To add an app from a subdirectory later, such as backend or apps/web, its create_service tool takes the project, a name for the new app, and a root to build from. After that, one push to the branch rebuilds and redeploys every app connected to that repository and branch (builds and configuration docs).

In the portal at deploy.candorhost.com, importing a repository deploys it from the repository root, so the import only succeeds when something deployable sits there; a repo with only web/ and api/ folders and nothing at the root is refused. Once an app exists, you can point it at a subdirectory with Root directory under its Settings tab and redeploy. The portal doesn’t yet add a second app from the same repository, so use the MCP for the two-app layout in this guide.

If you’d rather pin settings in code, a .candorfile at the repository root holds one block per service, and its root key sets the subdirectory to build from (.candorfile docs):

services:
  my-product-web:
    root: web
  my-product-api:
    root: api
    database: postgres

The service names must match your services exactly, so copy them from the portal. deploy_from_repo names each app after the project and its folder, so a my-product project gets my-product-web and my-product-api. A portal import names its single app <project>-app, and changing its root directory later doesn’t rename it. Settings in that file override both portal settings and detection, so it’s the place to fix things like a start command for the API.

How do you put /api on the same domain?

Make the frontend’s server a thin proxy: anything under /api/ is forwarded to the API’s private address, and everything else is served by the frontend as usual. This needs the frontend to run a server process; a purely static build has nothing to run it. Put your custom domain on the frontend app (on Candor Deploy, its Domains tab), and the API is reachable through it.

The proxy needs the API’s private address. On Candor Deploy, apps that deploy_from_repo deploys together each receive their siblings’ in-cluster addresses as environment variables named <DIR>_URL. A backend service becomes BACKEND_URL=http://svc-..., reachable from the other apps over the private network, and a value you set yourself wins (.candorfile docs). With an api/ folder, look for API_URL; check the app’s environment in the portal to confirm the exact name. If you added the API later with create_service, or through the portal, nothing is injected: set API_URL=http://svc-<api-app-name> on the frontend yourself, using the API app’s name.

That address is reachable from your apps, not from a visitor’s browser. Putting it in client-side code, or baking it into a bundle as a public build variable, gives the browser a URL it can’t reach. The private URL belongs in server-side code only.

A minimal Node example using Express 5 and http-proxy-middleware. It uses ES module import, so add "type": "module" to web/package.json (or name the file server.mjs). Also add "start": "node server.js" to its scripts, and make sure its build script emits dist/, since the server serves files from there:

import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";

const app = express();
const target = process.env.API_URL; // private address, e.g. http://svc-my-product-api

if (target) {
  // Keeps the prefix: /api/orders reaches the API as /api/orders, like the dev proxy below.
  // Matches /api and /api/... only, so a page like /apiary stays with the frontend.
  const pathFilter = (path) => path === "/api" || path.startsWith("/api/");
  app.use(createProxyMiddleware({ target, pathFilter, changeOrigin: true, proxyTimeout: 30_000 }));
} else {
  app.use("/api", (_req, res) => res.status(503).send("API_URL is not set"));
}
app.use(express.static("dist"));
app.get("/{*splat}", (_req, res) => res.sendFile("index.html", { root: "dist" }));

app.listen(process.env.PORT || 8080, "0.0.0.0");

This proxy forwards the path unchanged, so the API serves its routes under /api.

Most full-stack frameworks offer the same thing as a server route or rewrite. Whatever you use, check when the target is read. Some framework configuration is evaluated at build time, which means the API address would need to exist during the build and would be frozen into the output. Reading process.env.API_URL inside a request handler at runtime avoids that question entirely.

A few details that save debugging time:

  • Match the API’s routes to the prefix. The proxy above keeps /api. If your API’s routes are /orders rather than /api/orders, add a path rewrite to the proxy, do the same in development, and test it.
  • Remember the API still has its own public URL. On Candor Deploy, every app keeps its generated https://<app>-<account-number>.candorhost.app address (domains docs), so the API can be called directly, not just through the frontend. Keep its authentication and rate limits.
  • Forward the client IP carefully. Behind a proxy, the API sees the frontend as the caller. Trust X-Forwarded-For only from a proxy you control, such as your frontend’s server; anyone calling the API’s public URL directly can set that header to any value.
  • Keep the API listening on $PORT too. It’s still an app with its own health check.
  • Set timeouts. A proxy with no timeout turns a slow API into a frontend that hangs.

What about local development?

Mirror production so the frontend code never changes. Run the API on a local port, and point the dev server’s proxy at it:

// vite.config.js
export default {
  server: { proxy: { "/api": "http://localhost:4000" } },
};

The frontend keeps calling /api/orders. Locally, the dev server forwards it; in production, the frontend’s server does. Vite’s proxy keeps the /api prefix, which matches the Express proxy above. Same code, same paths, different target.

What usually breaks?

  • The stack isn’t recognized. The build ran at the repository root. Set the app’s root directory to its subdirectory (troubleshooting docs).
  • The domain shows the API’s JSON. The domain is attached to the API. Attach it to the frontend app.
  • /api returns 404 in production only. The proxy is a dev-server feature that never shipped, or one environment strips the prefix and the other doesn’t.
  • Network errors to http://svc-... in the browser console. The private URL leaked into client code. Move the call behind the proxy.
  • Changed variables don’t take effect. On Candor Deploy, environment changes apply on the next deploy, so redeploy after saving (builds and configuration docs).

What’s the next step?

Read the monorepo section of the builds and configuration docs, then confirm both of your app folders build on their own before your first push.