Skip to content

IPs & certificates

A Service owns its App and manages that App’s addresses. It is public by default and reachable only inside your organization with public: false. Fly’s proxy load-balances {app}.fly.dev across Machines that publish a proxy service.

IPs and certificates you declare yourself attach to a Fly.App: use them for Machines, for Services grouped in one App, and for your own domain.

A Service with port listens inside the Machine. Alchemy publishes HTTP 80 and HTTPS 443 on the Fly proxy in front of it, and allocates a shared IPv4 and an IPv6 on the Service’s App. Both are free.

export default class Api extends Fly.Service<Api>()(
"Api",
{ main: import.meta.url, region: "iad", port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Yield the Service in the Stack. api.url is https://{appName}.fly.dev.

export default Alchemy.Stack(
"MyApp",
{ providers: Fly.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const api = yield* Api;
return { url: api.url };
}),
);

Fly’s proxy terminates TLS on 443. The Service still listens on port inside the Machine.

public: false keeps a Service off the internet. Its App gets only a free Flycast address, which Fly’s proxy serves inside your organization’s private network.

export default class Api extends Fly.Service<Api>()(
"Api",
{ main: import.meta.url, region: "iad", port: 3000 },
{ main: import.meta.url, region: "iad", port: 3000, public: false },

Fly issues no TLS certificate for .flycast, so a private Service publishes plain HTTP on port 80. url is undefined and privateUrl is http://{appName}.flycast.

Other Services call it by binding it with Fly.bindService, which returns a typed client for its methods — see Call one Service from another. Bound calls go to privateUrl through Fly’s proxy, so service checks, autostart, and the traffic switch of a blue/green deployment apply. Only Services that bind the target receive its caller token, and the target checks Fly’s Fly-Src signature, so a method answers only bound callers on Fly’s private network. The traffic is plain HTTP inside Fly’s WireGuard-encrypted network.

Turning public on or off updates the Service in place. Its App and hostname stay the same.

Fly’s default private network spans the organization, so every App in the org can call a private Service. Set network: yield* Fly.stackNetwork on each Service to put them on a network unique to the stack and stage. Services on it bind each other; Apps on any other network, including the default one, get bad address for the .flycast and .internal names, and binding across networks fails the caller’s deploy with Fly.ServiceUnreachable. A public Service on the network still serves url, so it becomes the stack’s single entry point. Connect Services walks through it.

A Machine, or a Service that joins a Fly.App with app, uses the addresses on that App. Alchemy manages none for it. {app}.fly.dev does not answer over IPv4 until the App has an IpAssignment. Allocate a shared Anycast IPv4:

export const Site = Fly.App("Site");
export const PublicIp = Fly.IpAssignment("Shared", {
app: Site,
type: "shared_v4",
});

Yield it next to the resources in the App.

Effect.gen(function* () {
const api = yield* Api;
const ip = yield* PublicIp;
return { url: api.url, ip: ip.ip };
}),

v6 is free dedicated IPv6. v4 is billed dedicated IPv4 and may 400 if the org has no quota. Prefer shared_v4 or v6 in tests.

private_v6 is a free Flycast address that is not reachable from the internet. Allocate only private_v6 on an App to keep everything in it private:

export const Backend = Fly.App("Backend");
export const BackendIp = Fly.IpAssignment("Flycast", {
app: Backend,
type: "private_v6",
});

Resources in the App still publish a port for the proxy to forward to. Publish plain HTTP on port 80 without forceHttps:

export default class Worker extends Fly.Service<Worker>()(
"Worker",
{
app: Backend,
main: import.meta.url,
port: 3000,
services: [{
protocol: "tcp",
internalPort: 3000,
ports: [{ port: 80, handlers: ["http"] }],
}],
},
/* ... */
) {}

Other Apps on the App’s network reach it at http://{appName}.flycast. A Service in the App is also bindable: its deploy adds the Flycast address itself.

network places the address on a named private network instead of the organization default. The network must already exist: an App created with the same network creates it, and an unknown name fails with NetworkNotFound. Changing network replaces the assignment.

A Certificate covers a hostname on a Fly.App. It cannot target a Service’s own App yet, so run the Service in that App with app: Site and allocate its addresses there. Default kind is "acme" (Let’s Encrypt).

export const V6 = Fly.IpAssignment("V6", {
app: Site,
type: "v6",
});
export const Www = Fly.Certificate("Www", {
app: Site,
hostname: "www.example.com",
kind: "acme",
});

Yield Www in the Stack. Point DNS at the App. An A record for www to PublicIp.ip. An AAAA record to V6.ip. Plus whatever Www.dnsRequirements lists for the ACME challenge.

Fly’s proxy terminates TLS on 443 once the certificate is configured.

"custom" uploads a PEM (fullchain + privateKey). Hostname is the identity. Changing app, hostname, or kind replaces.

Use alchemy/ACME when you want to choose the certificate authority or issue a wildcard with DNS-01. The account and certificate are independent of Fly; this example uploads the result to a Fly App.

import * as ACME from "alchemy/ACME";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Fly from "alchemy/Fly";
import * as Layer from "effect/Layer";
const providers = Layer.mergeAll(
ACME.providers(),
Cloudflare.providers(),
Fly.providers(),
);

Use providers in the Stack configuration. Inside the Stack’s Effect, resolve your existing Cloudflare Zone and Fly Site, then issue and upload:

const zone = yield* Zone;
const site = yield* Site;
const account = yield* ACME.Account("LetsEncrypt", {
ca: ACME.LetsEncrypt,
contact: ["mailto:ops@example.com"],
termsOfServiceAgreed: true,
});
const certificate = yield* ACME.Certificate("Wildcard", {
account,
identifiers: ["*.example.com"],
solver: Cloudflare.DNS.AcmeSolver(zone),
});
yield* Fly.Certificate("WildcardUpload", {
app: site,
hostname: "*.example.com",
kind: "custom",
fullchain: certificate.chain,
privateKey: certificate.privateKey,
});

The DNS solver publishes and removes the challenge TXT records. Cloudflare’s solver waits 60 seconds before validation so cached challenge values can expire. Start with ACME.LetsEncryptStaging while developing; its certificates are not trusted by browsers.

Renewal is evaluated when you deploy, with a default threshold of 30 days before expiry. Schedule deployments if you need automatic renewal. Account and certificate private keys are persisted in stack state as redacted values; protect the state backend as secret material. Redaction alone is not encryption.

A Service can bind Fly.WriteCertificates(Site) to request, upload, inspect, check, and remove certificates without redeploying. Provide Fly.WriteCertificatesHttp on the Service Effect. For runtime issuance, ACME.IssueCertificate and ACME.IssueCertificateHttp bind an existing account; pass a runtime DNS solver to each issuance call.

Runtime operations do not become stack resources. Your application owns renewal scheduling, revocation, and removal of certificates it creates this way. Verify that the chosen certificate authority is reachable from your runtime.

Follow Runtime issuance for an authenticated Worker example, Using certificates for Fly uploads, and Renewal & revocation for scheduling and deletion policies. The IssueCertificate and WriteCertificates references document the binding methods.

The tutorial deploys a public Service. Connect Services binds private Services to each other on a stack network. Services covers private Services and grouping Services in one App. See the IpAssignment and Certificate references.