Kristóf's blog

27 May 2023

Building a web app on Fly.io: Scaffolding

I’ve wanted to explore Fly.io for a while now. It’s a platform that provides a global application delivery network, focusing on simplifying the deployment and management of infrastructure. Let’s build a web app together!

Fly has a free tier, which although doesn’t include much, is still plenty enough for everybody’s favorite language, Go plus a Postgres instance. In any case, it’s a good starting point for new projects, later migrating to more mature (and expensive) solutions once the need arises.

I’ve chosen for the app to be a Messenger clone called Gourier — a compounding of the words Go and courier. We Gophers love these, don’t we? ( ͡° ͜ʖ ͡°)

Setting up the project

Our first step is to sign up for Fly. It’s rather straightforward, so I won’t delve into that. We also need to install flyctl to access and manage our platform. On Linux, we can just go ahead and run
$ curl -L https://fly.io/install.sh | sh.

Tip: don’t forget to add the folder of the flyctl executable to your PATH to be able to call the command directly.

Having installed flyctl, let’s make an empty folder our project will live in with
$ mkdir gourier.

We need to add a couple of files. Here’s the directory structure I’ve gone for:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.
├── client
│   └── web
│       └── .gitkeep
├── server
│   ├── infra
│   │   └── Dockerfile
│   └── src
│       ├── go.mod
│       ├── go.sum
│       └── main.go
└── .gitignore

You can find the current state of the repo here. We have a folder for both the clients (there can be multiple in the future) and the back-end server. Infrastructure stuff is also separated from code, since they aren’t directly related.

For running the server, I’ve gone with Docker. It’s a battle-tested technology, and makes our server portable, requiring minimal changes should we ever decide to ditch Fly later.

Fly can also run a ton of languages without (explicit) containerization. See the complete list here.

Let’s take a look at the contents of the files. First, the source code of the server. It’s just a simple HTTP server listening on port 8080 for now, because we just want to see that our app is running correctly. Here is main.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
		writer.Write([]byte("Hello world!"))
	})

	log.Println("starting server...")
	if err := http.ListenAndServe("0.0.0.0:8080", nil); err != nil {
		log.Fatal(fmt.Errorf("could not start server: %w", err))
	}
}

A Fly quirk is that we need to explicitly listen on 0.0.0.0 instead of other addresses such as 127.0.0.1. ¯\_(ツ)_/¯

The other interesting file is the Dockerfile. Let’s leave this empty for now, at this point, we just want to create a Fly app. We will get back to it later.

We can then go on to authorize our CLI by running $ fly auth login and then make an app using $ fly launch. Fly detects the presence of Dockerfiles, runtime frameworks or source code and adjusts what the launch command does. Since we already have a Dockerfile, it is going to assume we want to deploy a containerized application. Bingo!

After running launch, Fly is going to ask us to answer a couple of questions here.

  1. First, the name of the app. Write gourier and press enter.
  2. Region: you can use the arrow keys to select one. You can just choose whichever is closest to you for now. I selected Amsterdam (ams).
  3. Dockerignore: we don’t need one, so just answer N here.

If all went well, we should have our app ready, but not deployed yet.

Tip: you can verify this by running $ fly apps list.

Deploying our app

Let’s turn out attention back onto the Dockerfile. The free tier of Fly gives us 3 shared-cpu-1x 256mb VMs (among other things). Even though it’s enough for Go, this means we need to pay attention to our RAM usage. Instead of a simple one-stage Dockerfile, we are going to use a multi-stage one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM golang:1.20 as builder

WORKDIR /gourier
COPY server/src ./
RUN CGO_ENABLED=0 go build -o /bin/gourier ./main.go

FROM scratch

COPY --from=builder /bin/gourier /bin/gourier
EXPOSE 8080
ENTRYPOINT ["/bin/gourier"]

The first stage is the compilation using the golang:1.20 image. This includes Go and is based on a Debian image, so it’s perfect for compiling, but it’s huge and is running things that are not necessary for our server. We use it for creating our own, smaller image that only includes a binary.

During the compilation, we set the value CGO_ENABLED=0 to disable cgo. This is very important in this case, because by default cgo is enabled, and the compiled executable will require some OS libraries to be loaded dynamically that are missing from the scratch image. Amongst them, the most important is the DNS resolver for net.

After building the executable, we take the scratch image, copy our custom-built executable into it, and start running the server. This way our image is going to be small and the only thing running will be our server.

We can now verify our setup is working before messing around with Fly. For this, we need to build the Docker image and run it.

$ docker build -t gourier:0.1 -f server/infra/Dockerfile .
$ docker run -p 127.0.0.1:80:8080/tcp gourier:0.1

Head to localhost and if all went well, you should be greeted by our API! We’re ready to deploy our app to Fly, just proceed and run:
$ fly deploy --dockerfile server/infra/Dockerfile

Tip: you can alias long commands with a makefile, so they’re easier to remember and team members have access to them. Prefer short commands such as make deploy.

This command will cause fly to build the image on its own infrastructure using our Dockerfile and deploy two replicas. After the deploy, you will see a link for the live deployment.

Adding a custom domain

Now that our app is working, we just have a last (optional) task, giving it a nice URL. This is very simple to do with Fly. First, we’re going to need a domain. I’m going to use kristof.sh and redirect the gourier subdomain to our app.

For our domain to point to the app, we really just need to set the A and AAAA DNS records to the IPv4 and v6 address of our deployments. We can find these by running
$ fly ip list -a gourier.

The next steps depend on your domain provider. I use Cloudflare to manage my systems. In CF, you just need to go to DNS -> Records -> Add record. Ideally, you’d add both IPv4 and v6 records.

Tip: if you’re also using CF, make sure to disable CF proxying since they will collide with the TLS certs of Fly (or use CF certs).

After giving time for the DNS records to take effect, we should be able to reach our app through the new domain, with a big warning about it being unsafe. This is because we don’t have TLS certs issued.

Fly does automatic TLS termination, lifting the burden from us, and making it needed to only listen to HTTP in our system. We can add the missing certs by running (obviously, with your own domain)
$ fly certs create -a gourier gourier.kristof.sh.

You can monitor the state of the certs using
$ fly certs show -a gourier gourier.kristof.sh.

Monitoring our deployment

Fly has a very nice dashboard where we can see stats about our deployment. You can reach the logs of the running containers, see metrics and running instances. There is also a Grafana dashboard, if you prefer a more “standard” interface.

Afterthoughts

I hope this was an informative article and you enjoyed it. In the next episode, I’m going to dig into adding CI/CD pipelines to our system with GitHub. Don’t hesitate to reach out to me with any questions or thoughts!