Application & framework security

How do I deploy an Arcjet-protected app to Fly.io?

Install the Arcjet SDK, set ARCJET_KEY as a Fly secret, and deploy. Fly injects FLY_APP_NAME, so the SDK trusts Fly-Client-IP and uses the Fly decide endpoint. Add shield, detectBot, and a sliding window in the handler.

6 min read
In short: Install the Arcjet SDK, set ARCJET_KEY as a Fly secret, and deploy. Fly injects FLY_APP_NAME, so the SDK trusts Fly-Client-IP and uses the Fly decide endpoint. Add shield, detectBot, and a sliding window in the handler.

How do I deploy an Arcjet-protected app to Fly.io?

Install the Arcjet SDK in your app, set ARCJET_KEY as a Fly secret, and deploy with fly deploy. Fly terminates TLS and proxies traffic to your Machines. Arcjet sees FLY_APP_NAME, treats the request as Fly traffic, reads the client IP from Fly-Client-IP, and sends decide calls to https://fly.decide.arcjet.com instead of the default AWS endpoint.

You do not add a special Fly adapter. The v1 client is the same object you use locally: a key and a rules array. Create it once outside the handler.

import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
slidingWindow({ mode: "LIVE", interval: 60, max: 100 }),
],
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
const status = decision.reason.isRateLimit() ? 429 : 403;
return Response.json({ error: "Denied" }, { status });
}
// Your handler
}

Swap @arcjet/next for @arcjet/node, @arcjet/bun, or @arcjet/sveltekit if that is your runtime. The constructor fields do not change.

The quickest way to see this running is the Arcjet Next.js Fly example:

Terminal window
git clone git@github.com:arcjet/arcjet-example-nextjs-fly.git
cd arcjet-example-nextjs-fly
fly launch --no-deploy
fly ext arcjet create
fly deploy

fly ext arcjet create creates an Arcjet site mapped to the Fly app and writes ARCJET_KEY as a Fly secret. Open the dashboard with fly ext arcjet dashboard. If the extension command fails (Fly has had provision bugs), create a site at app.arcjet.com and set the secret yourself: fly secrets set ARCJET_KEY=ajkey_....

How does Arcjet detect the client IP on Fly.io?

Fly Proxy accepts the connection and sets Fly-Client-IP to the address it saw. That is the address you want for rate limits, bot analysis, and Shield. Do not take the left-most value of X-Forwarded-For. Clients can prepend spoofed hops.

Arcjet trusts Fly-Client-IP only after it detects Fly. Detection is the non-empty FLY_APP_NAME environment variable that Fly injects into every Machine. You do not set that variable in .env. You also do not list Fly edge IPs in proxies. Platform detection is the safer path: a request that never reached Fly cannot mint FLY_APP_NAME, so a forged Fly-Client-IP header is ignored.

You still configure proxies when something sits in front of Fly (Cloudflare, another load balancer). In that case Fly-Client-IP is the proxy, not the browser, and you walk X-Forwarded-For after naming the hops you trust. The same problem appears on Firebase; detect the client IP on Firebase covers the static-list versus platform-header trade-off.

import arcjet from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [],
// Only when you terminate TLS in front of Fly.
proxies: ["203.0.113.100", "203.0.113.0/24"],
});

In development, Arcjet falls back to 127.0.0.1 when no public IP is present. You can override the client IP with the x-arcjet-ip header locally. That header is ignored in production.

How do I add rate limiting and bot protection on Fly?

Put the rules in the handler that owns the work. Middleware does not have the route context you need to return a JSON 429 to an API client or a form error to a signup page.

A public API can start with a sliding window keyed on IP (the default when you omit characteristics). Login, password reset, and signup need tighter windows and a bot rule. Authenticated routes should add a characteristic you already have, such as userId or an API key, so an office NAT does not share one budget.

import arcjet, { detectBot, slidingWindow } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
detectBot({
mode: "LIVE",
allow: ["CATEGORY:SEARCH_ENGINE"],
}),
slidingWindow({
mode: "LIVE",
interval: 60,
max: 20,
}),
],
});

Fly is a popular target for Go services, and the Go SDK handles the proxy question with an explicit Platform rather than environment detection:

aj, err := arcjet.NewClient(arcjet.Config{
Platform: arcjet.PlatformFlyIo,
Rules: []arcjet.Rule{
arcjet.Shield(arcjet.ShieldOptions{Mode: arcjet.ModeLive}),
arcjet.DetectBot(arcjet.BotOptions{
Mode: arcjet.ModeLive,
Allow: []string{arcjet.BotCategorySearchEngine},
}),
arcjet.SlidingWindow(arcjet.SlidingWindowOptions{
Mode: arcjet.ModeLive,
Interval: time.Minute,
MaxRequests: 100,
}),
},
})

Interval is a time.Duration, so write time.Minute, not 60. The Go SDK is pre-release, so pin an exact version.

detectBot({ allow: [] }) denies every automated client, including curl. That is a good first test on Fly: curl https://your-app.fly.dev/api/route returns 403 once the bot rule is live. Search-engine crawlers need an explicit allow, as in the snippets above. See the rate limiting guide for algorithm choice (sliding window versus token bucket versus fixed window).

Start new rules in DRY_RUN if you are unsure of the ceiling. The decision is computed and logged, but the conclusion stays ALLOW. Flip to LIVE after you inspect a day of traffic in the Arcjet dashboard.

How do I store ARCJET_KEY on Fly?

Treat ARCJET_KEY as a secret, not an environment variable in fly.toml. Keys start with ajkey_. Fly encrypts secrets and injects them at runtime.

Terminal window
fly secrets set ARCJET_KEY=ajkey_your_site_key
fly secrets list

Do not prefix the key with NEXT_PUBLIC_. That would copy it into the browser bundle. If you build a Next.js image, scan the image the same way you scan any other container. Secret scanning in Next.js builds and secure container deployments cover the CI steps.

How do I verify the Fly deployment?

After fly deploy:

  1. Hit a protected route from a browser. You get a normal 200.
  2. Hit the same route with curl. A live bot rule returns 403.
  3. Burst the route past max. A live rate limit returns 429 on the next request.
  4. Run fly ext arcjet dashboard (or open the site in the Arcjet console) and confirm the client IP is a public address, not a Fly Machine IP and not 127.0.0.1.

If the dashboard shows an empty or local IP, the SDK did not detect Fly. Check that FLY_APP_NAME is present in the Machine (fly ssh console -C 'printenv FLY_APP_NAME') and that you are not overwriting it.

What else should I harden on Fly?

Fly builds and runs a container. Application rules do not replace image hygiene. Run as a non-root USER, pin Node 22 (or your current LTS) on a current Debian or Wolfi base, and keep the filesystem read-only unless you mount a tmpfs for cache. Those steps live in secure container deployments.

Fly already isolates Machines, encrypts the private network, and absorbs volumetric DDoS at the edge. Arcjet covers the request path Fly cannot see: per-user limits, bot classification, Shield, and signup protection. Use both.

Frequently asked questions

How do I deploy an Arcjet-protected app to Fly.io?

Install the SDK, create a client with key and rules, set ARCJET_KEY with fly secrets set or fly ext arcjet create, then fly deploy. The SDK detects FLY_APP_NAME and uses Fly-Client-IP plus the Fly decide endpoint.

How does Arcjet get the client IP on Fly.io?

After it sees a non-empty FLY_APP_NAME, it reads Fly-Client-IP. Do not take the left-most X-Forwarded-For value. Add proxies only if another load balancer sits in front of Fly.

Do I need a Fly-specific Arcjet adapter?

No. The v1 client is the same key plus rules array you use locally. Swap @arcjet/next for @arcjet/node or another runtime package.

What if fly ext arcjet create fails?

Create a site at app.arcjet.com and run fly secrets set ARCJET_KEY=ajkey_.... Then fly deploy and confirm decisions in the Arcjet dashboard.

How do I verify bot and rate-limit rules on Fly?

A browser should get 200. curl against a live detectBot({ allow: [] }) rule should get 403. Bursting past max should get 429. The dashboard should show a public client IP, not a Machine IP.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.