Deploying to Fly.io using Dagger and Github

Deploying to Fly.io using Dagger and Github

Learn how to streamline your deployment process using Fly.io, GitHub Actions, and Dagger. This guide walks you through setting up automatic deployments a simple todo app, from basic Fly.io CLI usage to creating efficient, reusable deployment pipelines with Dagger.

I was looking for a simple service where I could, ideally, run a single CLI command and deploy a simple Javascript or Typescript server. One of the suggestions was Fly.io. I've later also tried Render, which works similarly.

Starting with Fly.io

The deployment to Fly.io is fairly straightforward. Once you install the CLI and configure your app (most of it is automatic, i.e. just confirm the defaults), you can deploy your app with a single command -- fly deploy. However, we could do better than that and do automatic deployments on every push to the main branch.
I was hoping Fly.io would integrate with GitHub automatically where I could select my repo and it would automatically do deploys. However, at the time of writing this, Fly.io didn't support that. Which means, you have to configure the GitHub action on your own. Let's see how we can do that!

Setup Github action for Fly.io

I am going to assume you have the CLI and app set up already. If not, you can fork my todo app as an example. Alternatively, create a simple server yourself with bun create elysia app. You can follow the instructions here.
Back to Github actions.
To deploy to Fly.io from a Github action you'll need a token to authenticate with Fly.io. You can do that by running the fly CLI inside your app folder:
fly tokens create deploy -x 999999h
Take the output of the command and go to your Github repo to store it as a repository secret. This will make the token available for use in the Github action.
Open your Github repository and:
  1. Click Settings.
  2. From the Secrets and variables dropdown click Actions.
  3. Click New repository secret.
  4. Paste the output of the command above in the Secret field.
  5. Name the secret FLY_API_TOKEN.
  6. Click Add secret.
Let's create the Github action! In your repository, create a new file in the .github/workflows folder and name it deploy.yaml:
name: Fly Deploy
on:
  push:
    branches:
      - main
jobs:
  deploy:
    name: Deploy app
    runs-on: ubuntu-latest
    concurrency: deploy-group
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        working-directory: ./app
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
The action is fairly straightforward. It runs on every push to the main branch, and it checks out the code from the repository using the checkout action. Once the code is checked out, it runs the flyctl deploy command using the superfly/flyctl-actions/setup-flyctl action.

Note

Note that if you're app is in a subfolder, don't forget to set the working-directory to the correct path. In my case, I created the app in the ./app folder so I set the working-directory field to that value.
That's it! Now every push to the main branch triggers a deployment to Fly.io. If you had any database migrations or other setup steps, perhaps running unit tests you could see how the Github action could become a bit more complex. So before we get to that spot, I'll show you another tool that I looked at recently - Dagger.

Daggerize your scripts

Dagger is a tool that allows you to replace your scripts used for setup, building, testing, deploying and bunch of other things, with functions written in a programming language. In this article I'll use Go, but you can also write Dagger functions in Python and TypeScript.
One of the reasons why I typically reach for a shell script is that it's easy to write and I can run it right away. You can say writing simple script with Go/Python/TypeScript isn't that hard either, but I'd disagree. It's not the "writing the code" that makes it hard and tedious for me, it's more of the "setting up the environment" part and "download the tooling and dependencies" (make sure you get the right version!), packaging the "script" etc.
This is what Dagger made easy for me. I can bootstrap the Dagger function with a single command (dagger init), write the function and run it without ever downloading any dependencies or setting up the environment!
To run a Dagger function you can use call command in the Dager CLI (e.g. dagger call [function_name]). I am not going to write a walkthrough, because the quickstart in Dagger's docs is great, so check that out if you're interested.

Writing a Dagger function for Fly.io

Back to the Github action and the Fly.io stuff. Here's what I did in terms of writing a Dagger function to replace or rather abstract the flyctl CLI. The full source is here, but sinces there's not much to it, I'll paste the functions here as well:
// Deploy deploys an app from the src folder to Fly.io
func (m *Flyio) Deploy(ctx context.Context,
	// +required
	src *Directory,
	// +required
	token *Secret) (string, error) {
	return m.FlyContainer(ctx, token).
		WithMountedDirectory("/src", src).
		WithWorkdir("/src").
		WithExec([]string{"/root/.fly/bin/flyctl", "deploy"}).
		Stdout(ctx)
}

// FlyContainer creates a container with the flyctl CLI installed
func (m *Flyio) FlyContainer(ctx context.Context, token *Secret) *Container {
	return dag.Container().
		From("alpine:3.20.0").
		WithExec([]string{"apk", "add", "curl"}).
		WithExec([]string{"curl", "-LO", "https://fly.io/install.sh"}).
		WithExec([]string{"sh", "install.sh"}).
		WithSecretVariable("FLY_API_TOKEN", token)
}
The FlyContainer function creates (and returns) a new Docker container based on alpine:3.20.0, installs curl, downlads the script and installs the Fly.io CLI and then mounts an environment variable FLY_API_TOKEN from the secret passed to the function (Secret is a concept in Dagger that allows you to input things like API keys, passwords, into Dagger functions without exposing them in the logs, for example).
The Deploy function uses the FlyContainer function to create a container, mounts the source directory that was pass-in by the user, and then runs the flyctl deploy command in that mounted folder and pipes the output to standard out (stdout).
When I said you don't need to download any dependencies or set up the environment, I meant it. You can run the Dagger function right away, without any setup. The Dagger CLI acts as a CLI for these functions - for example, here's what happens if you list the functions in this module I created:
$ dagger functions
Name            Description
deploy          Deploy deploys an app from the src folder to Fly.io
fly-container   FlyContainer creates a container with the flyctl CLI installed
Then, to run the deploy function, you can do something like this:
dagger call deploy --src ./projects/htmx-todo/app --token=env:FLYIO_TOKEN
Note the --src flag is the path to the source code of the app you want to deploy, and the --token flag is the deployment token. It's prefixed with env which is telling Dagger to read the value from the environment variable. Other options here are file, to read the secret value from a file, or cmd to run a command and use the output as the value.
You might wonder -- well, this is all great, but how can I run this same Dagger function from my environment? Do I need to clone the repo, or what do I do? This is the REALLY COOL part - when calling dagger call or dagger functions you can point to the location of the Dagger module (e.g. the Github repo for example where I published the module) and it will work the same way as if you had the module locally:
$ dagger functions  -m github.com/peterj/dagger-modules/flyio@46138d1028f721d7a0cdc03794c64aa063584f46

```console
Name            Description
deploy          Deploy deploys an app from the src folder to Fly.io
fly-container   FlyContainer creates a container with the flyctl CLI installed

Putting it all together

The last step is to run the Dagger function in the Github action. There's a published Github Action for running Dagger and to use it you can modify your workflow, something like this:
name: Fly Deploy with Dagger
on:
  push:
    branches:
      - main
jobs:
  deploy:
    name: Deploy app
    runs-on: ubuntu-latest
    concurrency: deploy-group
    steps:
      - uses: actions/checkout@v4
      - uses: dagger/dagger-for-github@v5
        with:
          verb: call
          module: github.com/peterj/dagger-modules/flyio@46138d1028f721d7a0cdc03794c64aa063584f46
          args: deploy --src $PWD --token=env:${{ secrets.FLY_API_TOKEN }}
The Github action looks more or less the same, but now if your deployment "script" changes, you can update the Dagger function(s), easily test them locally (without setting up anything) and you can be confident that if it works locally, it will work in the CI as well.

Related Posts

;