The path from localhost to the internet
You have a Go HTTP server running on your laptop. It handles requests, returns JSON, and feels solid. The next step is putting it on the internet so other people can use it. Fly.io makes that step straightforward. It takes your Go project, wraps it in a container, and runs it in a lightweight virtual machine close to your users. You do not need to manage servers, configure load balancers, or write complex infrastructure code.
Fly.io operates on a simple premise. Your code runs inside a Docker container. The platform handles the heavy lifting of provisioning virtual machines, routing traffic, and managing SSL certificates. When you deploy, Fly.io builds a container image from your source code, pushes it to its internal registry, and starts a machine running that image. The fly CLI automates the entire pipeline. It reads your project structure, generates a fly.toml configuration file, and handles the build and deploy steps. You are essentially shipping a container to a network of edge regions.
Containers are cheap to spin up and tear down. Treat them as immutable artifacts, not persistent servers.
Minimal deployment
Start with a basic Go web server. Create a new directory, initialize a module, and write a handler that listens on port 8080. Run go mod init example.com/myapp first so the compiler can resolve dependencies.
package main
import (
"fmt"
"log"
"net/http"
)
// HandleRoot prints a greeting and returns a 200 status code.
func HandleRoot(w http.ResponseWriter, r *http.Request) {
// Write directly to the response writer to avoid extra allocations.
fmt.Fprint(w, "Hello from Fly.io")
}
func main() {
// Register the route on the default mux.
http.HandleFunc("/", HandleRoot)
// Listen on all interfaces so the container network can reach it.
log.Println("server listening on :8080")
// log.Fatal calls os.Exit(1) if ListenAndServe returns an error.
log.Fatal(http.ListenAndServe(":8080", nil))
}
Install the Fly CLI, authenticate, and initialize the project. Run these commands in the same directory as your main.go file.
# Download and install the fly CLI tool into your PATH
curl -L https://fly.io/install.sh | sh
# Open a browser to authenticate with your Fly account
fly login
# Generate fly.toml and prepare the project without deploying yet
fly launch --no-deploy
The fly launch command scans your directory, detects the Go language, creates a default Dockerfile, and writes a fly.toml file. It asks for an app name and a region. Once the configuration is ready, deploy the app.
# Build the container, push it to the registry, and start the machine
fly deploy
Fly.io provisions a virtual machine, pulls the container image, and starts your process. The CLI prints a URL. Visit it and see your greeting.
Keep the initial deploy simple. Add complexity only when the app demands it.
What happens during the build
The fly launch command generates a Dockerfile that uses a multi-stage build. The first stage compiles your Go code into a static binary. The second stage copies that binary into a minimal Alpine Linux image. This keeps the final container small and secure. The fly.toml file tells Fly.io how to run the container. It specifies the internal port your app listens on, the external port for incoming traffic, and environment variables.
When you run fly deploy, the CLI builds the Docker image locally. It pushes the layers to Fly.io's container registry. The platform then schedules a Firecracker micro-VM in your chosen region. The VM pulls the image, starts the container, and routes traffic from the public internet to your app's internal port. If you update your code and run fly deploy again, Fly.io builds a new image, starts a new machine, and swaps traffic over once the new instance passes a health check. The old machine shuts down automatically.
Go compiles to a single binary by default. The build process sets CGO_ENABLED=0 to avoid linking against C libraries. This ensures the binary runs on any Linux distribution without missing shared objects. If your project uses cgo, the build fails with exec: "gcc": executable file not found in $PATH. Switch to pure Go dependencies or provide a custom Dockerfile that includes a C compiler.
Trust the multi-stage build. Small images start faster and cost less.
Realistic production setup
Real projects need more than a single file. They require dependency management, environment variables, and explicit port configuration. Fly.io expects your Go app to follow standard conventions. Your project should have a go.mod file. The build process runs go build automatically, but you can override it with a custom Dockerfile if you need specific build flags or third-party dependencies.
Here is how a typical fly.toml looks after initialization. The file uses TOML syntax and defines the app metadata, build settings, and network configuration.
# App identifier used in the Fly URL and dashboard
app = "my-go-app"
primary_region = "ord"
# Build configuration tells Fly how to compile the project
[build]
builder = "pack"
# Environment variables are injected into the container at runtime
[env]
PORT = "8080"
# Network settings map the external port to your app's internal port
[[services]]
internal_port = 8080
protocol = "tcp"
[[services.ports]]
port = 80
handlers = ["http"]
[[services.ports]]
port = 443
handlers = ["tls", "http"]
The [[services]] block is the most important section. It tells Fly.io's edge network where to send incoming requests. Your Go code must listen on the internal_port. If your app listens on :8080, the internal_port must match. The external ports 80 and 443 are handled by Fly's load balancer. You do not need to configure TLS in your Go code. The platform terminates SSL before traffic reaches your container.
Production apps also need graceful shutdown. Fly.io sends a SIGTERM signal before stopping the machine. Your Go code should listen for the signal and exit within a few seconds. Use context.Context to coordinate shutdown. The context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// Run starts the HTTP server and waits for a termination signal.
func Run() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Handle requests normally.
w.Write([]byte("running"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start the server in a separate goroutine so we can listen for signals.
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("listen error: %v", err)
}
}()
// Block until the OS sends SIGINT or SIGTERM.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Create a context with a 10 second deadline for graceful shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Stop accepting new requests and finish active ones.
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown error: %v", err)
}
}
func main() {
Run()
}
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Check every error that matters. Do not swallow them with _ unless you have a specific reason. The underscore discards a value intentionally. Use it sparingly with errors.
Configure ports explicitly. Guessing the network layout causes silent failures.
Common failure modes
Deployment fails for predictable reasons. The most common issue is binding to localhost instead of 0.0.0.0. If your Go code uses http.ListenAndServe("localhost:8080", nil), the container network cannot reach it. Fly.io routes traffic to the container's IP address, not the loopback interface. Change the bind address to 0.0.0.0 or use :8080 without a hostname.
Another frequent problem is missing dependencies. The build stage runs go build ./.... If your project has uncommitted changes or missing modules, the build fails. Run go mod tidy before deploying. The CLI will print a build error if the compiler rejects your code. You might see undefined: main if your package structure is wrong, or cannot find package if your go.mod is out of sync. Fix the build locally first. fly deploy will not succeed if the local build fails.
Environment variables are another trap. Fly.io injects variables defined in fly.toml or the dashboard into the container. Your Go code must read them at startup. If you forget to set a required variable, your app might panic or behave unexpectedly. Use os.Getenv or a configuration library to handle missing values gracefully. Do not hardcode secrets. Store them in the Fly dashboard or use fly secrets set.
Goroutine leaks also cause deployment issues. If your app spawns background goroutines that wait on channels or timers, they might prevent the container from shutting down cleanly. The worst goroutine bug is the one that never logs. Always have a cancellation path. Pass a context to every long-running goroutine. Close channels when the producer is done. Do not pass a *string for configuration. Strings are already cheap to pass by value.
Public names start with a capital letter. Private start lowercase. No keywords like public or private. Follow the convention and the code reads naturally.
Test the shutdown path locally. A hanging process breaks the deploy pipeline.
Choosing the right platform
Use Fly.io when you need a globally distributed network for a long-running Go service. Use a static hosting platform like Vercel or Netlify when you are deploying frontend assets or serverless functions that do not require persistent state. Use a traditional VPS or cloud provider like AWS EC2 when you need full control over the operating system, custom networking, or bare-metal performance. Use a managed PaaS like Render or Heroku when you want a simpler workflow without edge routing or multi-region deployment. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Pick the tool that matches your traffic pattern. Overengineering deployment costs more than the server.