Deploy to Fly via GitHub action
By Flavio Copes
Learn how to set up continuous deployment to Fly.io with a GitHub Actions workflow that runs flyctl deploy using a FLY_API_TOKEN secret on every push.
Fly.io is a very cool cloud hosting platform but one thing I miss is automatic deploy when I do a GitHub commit, as the services are not linked to GitHub. If you still need to get an app onto Fly, I wrote how to run an app on fly.io.
To add CD (Continuous Deployment) to Fly.io we need to create a GitHub action.
I built a free GitHub Actions deploy workflow generator that writes this file for Fly, Cloudflare Pages and VPS deploys, including the secrets you need to set.
I suppose you already have a Fly app running and a fly.toml file in your repo like I describe in how to run Astro Node SSR on Fly.
That fly.toml has to be committed. The GitHub runner has no copy of your laptop. flyctl deploy reads the app name and the build settings from that file. If it is in .gitignore, the action checks out the repo and has nothing to deploy.
In your project repo, create .github/workflows/fly.yml
name: Fly Deploy
on:
push:
branches:
- mod12
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
I took this from Fly’s continuous deployment guide. Fly’s guide still uses actions/checkout@v4. I use the current v7 actions for checkout and setup-node, because the v4 tags still target Node 20 and GitHub now warns about them. Node 20 left Actions runners on 23 September 2026. I also added the setup-node step, as the repo is an Astro site and I want the build to run on the LTS Node. When I wrote this that was Node 20. Today it’s Node 24, and that’s what the workflow above asks for. Node 20 is end-of-life now, so pick 24, or 22 if that’s what you run locally.
What the workflow is doing
on.push.branches is the trigger. GitHub runs this job only when you push to the listed branch. Other branches, pull requests, tags: ignored.
The example uses mod12 because that is the branch I was deploying. For most repos you want main (or master, if that is still the default):
on:
push:
branches:
- main
concurrency: deploy-group is a queue. If you push twice in a minute, the second job waits instead of shipping two images at the same time. The string is just a name. Keep it stable so every run of this workflow shares the same queue.
actions/checkout@v7 copies the repo onto the runner, fly.toml included.
superfly/flyctl-actions/setup-flyctl@master installs flyctl on that runner. The job does not need Docker, and you do not run fly auth login. The next step talks to Fly with the token.
flyctl deploy --remote-only builds the image on Fly’s remote builder, not on GitHub. --remote-only is the default now. It is still worth writing it down: the Ubuntu runner has no Docker daemon you want to depend on, and you do not want to wait while GitHub downloads a builder image. Fly already has one. The Dockerfile in the repo is sent to that builder.
setup-node does not build the Fly image. The remote builder does, using whatever FROM you put in the Dockerfile. Node on the runner is there for steps you run before flyctl deploy, like npm ci and tests. If you skip those, Fly still builds from the Dockerfile.
FLY_API_TOKEN is read from GitHub’s secrets store and injected only into that step. It never has to sit in the repo.
The deploy token
In your terminal, from the app directory, run
fly tokens create deploy -x 999999h
That prints a token that can deploy this one app. Copy the whole line, including the FlyV1 prefix and the space after it. If you paste only the second half, GitHub will store a string Fly rejects.
-x 999999h sets the expiry far in the future. Drop it if you want a shorter token and plan to rotate.
A deploy token is the right default for CI. It is scoped to the app whose fly.toml you are standing in. If the token leaks, the attacker can ship a new image of that app. They cannot create apps, delete machines in another app, or wander around the org.
The other tokens you might see:
- An org token can manage several apps. Use it only if one workflow deploys more than one app and you accept the extra blast radius.
fly auth tokenprints a personal token for your account. Fly’s own guide tells you not to put that in GitHub Actions. It can do anything you can do.
Add the token to the Actions secrets in GitHub:


Call the secret FLY_API_TOKEN and enter the value you got from fly tokens...
The name has to match the workflow. secrets.FLY_API_TOKEN looks up FLY_API_TOKEN. FLY_TOKEN, FLY_API, a typo, an environment secret you forgot to attach: the step runs with an empty token and Fly returns 401.


GitHub secrets vs fly secrets
Two different stores, easy to mix up.
GitHub secrets (Settings → Secrets and variables → Actions) are for the runner. FLY_API_TOKEN belongs there. So does anything a run: step needs, like an npm token for npm ci.
Fly secrets (fly secrets set DATABASE_URL=...) are for the running app. They become environment variables on the Machines. A deploy does not read them from GitHub. If the app needs DATABASE_URL, set it with fly secrets set, once, from your laptop. Putting it only in GitHub will not land it on the VM.
Monorepos and more than one app
flyctl deploy uses the fly.toml in the current directory and the app = name inside it. If that name is not the app you think, you ship to the wrong place.
In a monorepo, or with two Fly apps in one repo, point flyctl at the right file:
flyctl deploy --remote-only --config apps/web/fly.toml
Or at the app name:
flyctl deploy --remote-only -a blog
The deploy token still has to be allowed to touch that app. An app-scoped token from fly tokens create deploy only works for the app you created it in.
Common ways this fails
fly.tomlis not in git. The checkout step succeeds.flyctl deploythen has no config.- The
appname infly.tomldoes not match the app on Fly. You get a 404, or you deploy a different app. - The GitHub secret is not named
FLY_API_TOKEN, or you created an Environment secret and the job has noenvironment:key. - You cut off the
FlyV1prefix when you pasted the token. - Two workflows deploy the same app without
concurrency, and they stomp each other.
Optional: run tests before you ship
The workflow above deploys whatever you pushed. If you want the job to stop on a failed test, add the install and test steps before flyctl deploy. GitHub Actions fails the job on the first non-zero exit, so a red npm test never reaches Fly.
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm test
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Those npm commands run on the GitHub runner. The image Fly boots still comes from the Dockerfile on the remote builder. Keep the Node version here in the same major as the image, or you will test on 24 and ship 22.
Push to GitHub, you’ll see your action running:

You can see all the details of the action by clicking it:

In the jobs tab you can see what happened in the build:

Go on Fly and open the app: you’ll see a new release, published from the GitHub push. From now on the app is deployed on every commit.
All your deploys also have a “check” now to indicate that the action was successful (or not)

If a deploy is bad
Fly has no rollback command, so you redeploy the previous image.
List releases and their image tags:
fly releases --app blog --image
You get a table of versions, timestamps, and registry.fly.io/... references. Pick the one that was fine, then:
fly deploy --image registry.fly.io/blog:deployment-01HYYYYY
That boots the old image. It does not rebuild. It also does not undo a database migration, a fly.toml change, or a fly secrets set you ran in between. Those stay as they are. If the image is old enough, Fly may have pruned it from the registry, and this command will fail.
Want me to talk about your product? You can sponsor this site.
Related posts about services: