Fly.io is a surprisingly powerful platform for deploying small, fast applications, often feeling more like a high-performance VM than a typical PaaS.
Let’s get a Go app running.
First, the Go app itself. A minimal web server is all we need to see it deployed.
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from Fly.io! Your request was for: %s\n", r.URL.Path)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080" // Default to 8080 if PORT is not set
}
log.Printf("Server starting on port %s\n", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
This main.go listens on a port specified by the PORT environment variable, defaulting to 8080. This is crucial because Fly.io will assign a port dynamically.
To build this for deployment, we need a fly.toml configuration file. This file tells Fly.io how to build and run your app.
app = "my-go-app-example" # Replace with your desired app name
kill_signal = "SIGINT"
restart_command = "./my-go-app-example"
capture_warning_output = true
processes = []
[build]
builder = "heroku/buildpacks"
[deploy]
release_command = ""
[services]
http_checks = []
internal_port = 8080
concurrency = 1 # Adjust as needed for your app
[[services.ports]]
handlers = ["http"]
port = 80
[[services.tcp_checks]]
interval = "15s"
timeout = "4s"
The app name is your unique identifier on Fly.io. kill_signal and restart_command are important for graceful shutdowns and restarts. The [build] section specifies using Heroku-style buildpacks, which are excellent for Go. internal_port tells Fly.io which port your application inside the container will listen on. concurrency is how many instances of your app can run on a single VM. The [[services.ports]] section maps external HTTP traffic on port 80 to your application’s internal port.
We also need a Procfile. Even though we’re using buildpacks, Fly.io sometimes uses a Procfile to determine how to start the application.
web: ./my-go-app-example
This tells the buildpack that the web process type should execute our compiled Go binary.
To build the Go app, we’ll use a standard Go build command. For Fly.io’s buildpacks, it’s best to build statically linked binaries.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o my-go-app-example main.go
Here, GOOS=linux and GOARCH=amd64 ensure we’re building for a Linux environment, and CGO_ENABLED=0 creates a fully static binary, which simplifies deployment and avoids runtime dependency issues. The -o my-go-app-example names the output binary to match our Procfile and fly.toml.
Now, to deploy. First, make sure you have the flyctl CLI installed and are logged in (flyctl auth login).
flyctl deploy --local-only
The --local-only flag is fantastic. It builds your app on your machine and then uploads the resulting artifact to Fly.io, rather than having Fly.io build it on their servers. This is often faster and gives you more control.
If you skip --local-only, the buildpack will run on Fly.io’s infrastructure. You’ll see output like this:
==> Building image
--> Building image with Buildpack: heroku/buildpacks
...
==> Uploading image
Once the build and upload are complete, Fly.io will start your application. You can check its status:
flyctl status
And view the logs:
flyctl logs
You should see your "Server starting on port 8080" message.
Finally, to access your app, you need its public URL.
flyctl apps show my-go-app-example | grep "Hostname:"
This will give you something like https://my-go-app-example.fly.dev. Visiting this in your browser should show "Hello from Fly.io! Your request was for: /".
The most surprising thing about Fly.io’s networking is that it abstracts away the complexities of load balancing and TLS termination by default, presenting a single, stable *.fly.dev hostname for your application, even as it scales across multiple machines and regions.
When you run flyctl deploy, the flyctl CLI first checks if a fly.toml exists. If it does, it uses that configuration to orchestrate the deployment. It then builds your application, either locally with --local-only or using buildpacks on Fly.io’s servers. This built artifact is then containerized. Fly.io manages a fleet of machines across various global regions. When you deploy, flyctl tells the Fly.io API which region(s) to start your container in. The platform then handles distributing your containerized application to those machines, assigning it an internal port (which must match services.internal_port in your fly.toml), and configuring its global Anycast IP address to route traffic to your application’s services.ports definition. If you have multiple [[services.ports]] entries or multiple regions, Fly.io automatically handles the routing and load balancing.
A subtle but powerful aspect of flyctl is its ability to manage secrets. Instead of baking sensitive information directly into your Go code or build process, you can use flyctl secrets set MY_API_KEY=your_super_secret_value. These secrets are then injected as environment variables into your running application container, encrypted at rest and only available to your specific application instance. This significantly enhances the security posture of your deployed applications without requiring complex infrastructure changes.
The next step is often configuring a custom domain and enabling HTTPS for it.