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 constructor, 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: Alchemy transports the App name, secret name, and deployment’s org token to the Machine through resource Outputs. At runtime, get() reads 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}`;

get() reads 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.