Skip to content

Part 4: Secrets and Cleanup

A secret only this Service needs from .env is Config.redacted. See Secrets.

This part is for a value Fly should own: an App Secret Fly injects into every Machine. You will read it with GetSecret, then tear the stack down so billing stops.

Add a Secret next to the App. Wrap the value with Redacted.make so it is never logged:

src/app.ts
import * as Fly from "alchemy/Fly";
import * as Redacted from "effect/Redacted";
export const Site = Fly.App("Site");
export const PublicIp = Fly.IpAssignment("Shared", { /* ... */ });
export const ApiToken = Fly.Secret("ApiToken", {
app: Site,
name: "API_TOKEN",
value: Redacted.make("not-a-real-token"),
});

name is the env-var Fly injects into Machines (API_TOKEN). Omit it and Alchemy generates an ownership-stamped name instead.

Inside the Service’s init, bind the Secret — pass the declaration directly, no yielding required:

src/api.ts
import { Site } from "./app.ts";
import { ApiToken, Site } from "./app.ts";
Effect.gen(function* () {
const mount = yield* Fly.MountVolume({ path: "/data", sizeGb: 1 });
const fs = yield* FileSystem.FileSystem;
const get = yield* Fly.GetSecret(ApiToken);
return {
fetch: /* ... */,
};
}).pipe(Effect.provide(Fly.MountVolumeLive)),

GetSecret is a binding: at deploy time it can mint an App deploy token for the Machine, and at runtime it hands you a get() for this Secret. Listing every secret on the App is ListSecrets(Site) — Fly’s list API is app-scoped.

Bindings declare a capability; layers implement it. Provide GetSecretHttp next to MountVolumeLive:

}).pipe(
Effect.provide(Fly.MountVolumeLive),
Effect.provide(Fly.GetSecretHttp),
),

Add a /secret route that returns the secret’s name, never the plaintext:

if (url.pathname === "/health") {
return HttpServerResponse.json({ ok: true });
}
if (url.pathname === "/secret") {
const got = yield* get().pipe(Effect.orDie);
return HttpServerResponse.json({ name: got.name });
}
const file = `${mount.path}${url.pathname}`;

secrets.get() defaults to the bound Secret. Fly only returns plaintext from a Machine in the same App; from an Action you get metadata.

alchemy.run.ts
import { PublicIp, Site } from "./src/app.ts";
import { ApiToken, PublicIp, Site } from "./src/app.ts";
Effect.gen(function* () {
const site = yield* Site;
yield* PublicIp;
yield* ApiToken;
const api = yield* Api;
Terminal window
bun alchemy deploy
Plan: 1 to create, 1 to update

+ ApiToken (Fly.Secret)
~ Api (Fly.Service)

Proceed?
◉ Yes ○ No
 ApiToken (Fly.Secret) created
 Api (Fly.Service) updated
Terminal window
curl https://myapp-site-dev-a1b2c3d4.fly.dev/secret
# → {"name":"API_TOKEN"}

You’re done — tear everything down so Machine and Volume billing stops:

Terminal window
bun alchemy destroy

Alchemy deletes everything in reverse dependency order — Service, Secret, Volume, IP, App.

Over four parts you built a complete Fly deployment:

  • An App with a generated globally unique name
  • An HTTP Service bundled into a Machine, updated only when its code hash changes
  • A Volume mounted at /data whose contents outlive deploys
  • An App Secret readable from the Service, never logged
  • Services — background workers, env, multiple Services per App.
  • Machines — raw images without a bundle.
  • Volumes — snapshots, extend, region rules.
  • SecretsGetSecret / ListSecrets / WriteSecret, plus KMS SecretKey with Encrypt / Decrypt / Sign / Verify.
  • Testing — deploy this stack from an integration test and drive it over HTTP.
  • CI — run alchemy deploy from GitHub Actions with FLY_API_TOKEN.