Fly.io’s HTTP routing is actually a lightweight TCP proxy, not a full L7 proxy, meaning it primarily cares about establishing a TCP connection and then forwarding raw bytes.

Let’s see this in action. Imagine you have a simple Go application that listens on port 8080 and you want to expose it on Fly.io.

package main

import (
	"fmt"
	"log"
	"net"
)

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("Failed to listen on port 8080: %v", err)
	}
	defer listener.Close()
	fmt.Println("Listening on :8080")

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Failed to accept connection: %v", err)
			continue
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()
	log.Printf("Handling connection from %s", conn.RemoteAddr())

	// In a real app, you'd read and write data here.
	// For this example, we'll just acknowledge and close.
	_, err := conn.Write([]byte("Hello from Fly.io!\n"))
	if err != nil {
		log.Printf("Failed to write to connection: %v", err)
	}
}

To deploy this, you’d create a fly.toml like so:

app = "my-tcp-app"
primary_region = "ord"

[env]
PORT = "8080"

[[services]]
  internal_port = 8080
  protocol = "tcp"
  auto_stop_machines = true
  auto_deploy = true

  [services.concurrency]
    type = "connections"
    hard_limit = 500
    soft_limit = 400

When you fly deploy, Fly.io sets up its edge routers to listen on public ports (like 80, 443) and then establishes a TCP connection to your application’s internal_port (8080 in this case). Your application code then receives that raw TCP connection and handles it as it sees fit. The PORT environment variable is a convention Fly.io uses to tell your application which port to bind to within the container.

Now, what if you need to expose multiple TCP services? Fly.io’s routing mesh can handle this, but it requires careful configuration in your fly.toml. Let’s say you have a PostgreSQL database running inside a container on port 5432, and your Go app on 8080. You want to expose both.

Your fly.toml would look like this:

app = "my-multi-port-app"
primary_region = "ord"

[env]
  # Your app might still need its own port config
  GO_APP_PORT = "8080"
  DB_PORT = "5432" # If your app needs to know this

[[services]]
  # Service for the Go application
  protocol = "tcp"
  internal_port = 8080 # Your Go app listens here
  auto_stop_machines = true
  auto_deploy = true

  # You can optionally set a specific port for this service,
  # but usually Fly.io handles mapping public ports (80/443)
  # to internal TCP services. If you wanted to expose a non-standard
  # TCP port directly, you'd configure it here. For standard HTTP/S,
  # Fly handles the mapping automatically.
  # port = 8080 # This is often implicit for internal_port

[[services]]
  # Service for the PostgreSQL database
  protocol = "tcp"
  internal_port = 5432 # Your DB listens here
  auto_stop_machines = true
  auto_deploy = true
  # You'd typically NOT expose a database directly to the public internet
  # like this for security reasons. This is for internal service-to-service
  # communication or for specific, controlled access.
  # If you wanted to expose this on a public port *other than* 80/443,
  # you would specify it here. For instance, for a custom TCP service:
  # port = 9876

When you deploy this, Fly.io’s edge will establish TCP connections to both port 8080 and port 5432 on your machines. For incoming connections from the internet on ports 80/443, Fly will route them to the first [[services]] block that matches the protocol and port (usually HTTP/HTTPS, which goes to your primary web service). For other TCP protocols or if you explicitly map a public port (like port = 9876), Fly will establish a TCP connection from its edge to the corresponding internal_port on your Fly machine.

The key insight here is that internal_port in fly.toml is the port inside your container that Fly.io’s machine will connect to. The protocol = "tcp" tells the Fly.io routing mesh to establish a raw TCP connection. Fly.io’s edge routers handle the public-facing part, often abstracting away the specific public port unless you’re running a non-standard TCP service that needs its own dedicated public port mapping. For most web applications, Fly.io automatically maps incoming HTTP/S traffic on ports 80/443 to the internal_port defined in the first [[services]] block.

Understanding that the Fly.io router is fundamentally a TCP proxy, not an HTTP proxy, means you can expose any TCP-based service. It doesn’t inspect the application-level protocol unless you’re using specific integrations like the built-in HTTP/S handling.

If you’re trying to expose a service on a specific public port that isn’t 80 or 443, you’ll need to add a port directive to the [[services]] block in your fly.toml.

Want structured learning?

Take the full Fly-io course →