Serverless microservices without the public internet: service bindings on Ploy

Serverless microservices usually talk to each other over their public URLs. Service bindings on Ploy let one deployment call another in the same organization inside the platform, with no DNS lookup, no TLS handshake and no trip through the edge, while each service still deploys on its own.

Ploy logo on a dark background

Splitting an app into serverless microservices is easy to start. A frontend project, an API project, a billing worker, an auth service: each one gets its own repository folder, its own deploys and its own logs. The cost shows up in the calls between them. On most serverless platforms, the only way for one service to reach another is the same way your users do: over its public URL.

That means every internal call resolves DNS, opens a TLS connection, leaves through the network, comes back in through the edge and gets routed like a stranger's request. A page that fans out to three services pays that three times.

Ploy now has service bindings. A deployment can call another project in the same organization directly, routed inside the platform, without going over the public internet. Each service keeps its own project, its own deploys and its own logs, and the call between them works like a function call.

Why serverless microservices pay for the public internet

When two workers on the same platform talk over HTTPS, the request does everything a request from a browser on another continent does:

  • A DNS lookup for a hostname you already own.
  • A TLS handshake, to encrypt traffic between two services that run on the same infrastructure.
  • A trip through the edge: the load balancer, the router, the host lookup, the deployment resolution. All of it already happened once for the original request.

None of that buys you anything when both ends belong to the same organization. It just adds latency to every hop.

How service bindings work on Ploy

Ploy runs every deployment in the same runtime. When your worker calls a service binding, Ploy hands the request straight to the target deployment's request pipeline, on the same server that is already running your worker. No hostname is resolved, no TLS connection is opened, and the request never leaves the platform. The one exception is a target with a database, which is answered in the database's primary region like any of its requests and is forwarded there over the platform's internal network.

The target still runs as itself. It gets its own environment variables and bindings, writes its own logs, and its own CPU limits apply. Its static assets are served the same way they are for a public request. The only thing that changes is how the request got there.

Add a service binding in ploy.yaml

Say you have two projects in one organization: web, the public frontend, and api, the backend it calls. Declare the binding in the calling project's ploy.yaml:

kind: dynamic
build: pnpm build
out: dist
service:
API: api

API is the name the binding gets on your worker's env. api is the name of the target project. Run ploy types and the binding is typed as a ServiceBinding:

import type { ServiceBinding } from "@meetploy/types";
export interface Env {
API: ServiceBinding;
}

Then call it like fetch(). Relative URLs resolve against the target deployment:

export default {
async fetch(request, env) {
const response = await env.API.fetch("/users");
const users = await response.json();
return Response.json({ users });
},
};

You can also forward the incoming request as it is, with its method, headers and body:

const response = await env.API.fetch(
new Request(new URL("/orders", request.url), request),
);

By default the binding calls the target's production deployment, the one its production URL serves. To call a branch instead, use the object form:

service:
API:
project: api
branch: staging

When the target project deploys again, the binding usually switches to the new deployment within two minutes. You do not have to redeploy the caller. Bindings themselves change only with a production deploy of the calling project, so a branch push cannot point production at a different service.

Scoped to one organization

A service binding is not a back door between tenants. The rules are enforced by the platform, not by the code in your worker:

Rule What it means
Same organization only A deploy that names a project outside your organization fails
The caller declares it A deployment is only reachable through a binding another project's ploy.yaml declares
The platform picks the target The calling worker cannot choose which deployment answers; deployment pins are stripped
Loops are broken Chains like A → B → C are allowed, but after eight hops Ploy answers 508 Loop Detected

See internal traffic in your request logs

A request that arrived through a service binding is logged on the target deployment like any other request, marked Internal with the name of the project that called it. When you look at the api project's logs, you can tell calls from your own web frontend apart from traffic that came in from the internet.

Develop locally

ploy dev runs one project at a time, so there is no second project locally for the binding to reach. Point the binding at another dev server with an environment variable named after it:

PLOY_SERVICE_API_URL=http://localhost:3003

Without it, calling the binding returns 503 with a message that names the variable to set. It never quietly falls back to the deployed project.

When to split a service out

Service bindings make the call between services cheap, which changes when a split is worth it:

  • A public frontend in front of a backend. Keep the API in its own project with its own deploys, and call it from the frontend without a public hop.
  • A shared service. Auth, billing or search used by several projects, deployed once and bound from each consumer.
  • Independent release cycles. Ship the backend without redeploying every caller; bindings pick up the new deployment on their own.

Frequently Asked Questions

Do service bindings cost extra?

No. Service bindings are available on every plan, including self-hosted instances. The target deployment handles the request the same way it handles any other request.

Can a service binding reach a project in another organization?

No. The target has to be a project your organization owns. A deploy that names a project outside your organization fails, and if a target is later moved to another organization, calls through the binding answer 503.

Does the target need a public URL?

Every Ploy project still has its production URL, but your services no longer need to use it to talk to each other. Calls through a binding are routed inside the platform.

What happens if two services call each other in a loop?

Chains are allowed, but Ploy counts the hops. After eight bindings in one chain, the request is answered with 508 Loop Detected instead of recursing forever.