Fly.io’s fly.toml configuration allows you to run multiple processes within a single virtual machine, often referred to as "sidecar" processes. This is incredibly useful for tasks like log shipping, metrics collection, or running helper services that your main application depends on.

Let’s see this in action. Imagine you have a Go application that you want to deploy to Fly.io. Alongside your main Go app, you want to run a simple Nginx server that serves static assets from a shared volume.

Here’s a snippet of a fly.toml that accomplishes this:

app = "my-go-app-with-nginx"

[[services]]
  processes = ["web", "nginx"]
  internal_port = 8080
  tcp_checks = [
    { interval = "30s", timeout = "10s", restart_limit = 3 }
  ]

[[services.ports]]
  port = 80
  handlers = ["http"]

[[services.ports]]
  port = 443
  handlers = ["http"]

[deploy]
  strategy = "rolling"

[build]
  dockerfile = "Dockerfile"

[processes]
  web = "go run main.go"
  nginx = "nginx -g 'daemon off;'"

In this fly.toml:

  • app = "my-go-app-with-nginx": Defines the name of your Fly.io application.
  • [[services.processes = ["web", "nginx"]]: This is the key. It tells Fly.io that both the web process (your Go app) and the nginx process should be managed and started together within the same VM. They will share the same network namespace and can communicate with each other easily.
  • internal_port = 8080: This is the port your web process is listening on.
  • tcp_checks: Standard health checks for the service.
  • [[services.ports]]: Configures how external traffic is routed. Here, ports 80 and 443 are exposed.
  • [build]: Specifies how your application is built.
  • [processes]: This section defines the commands to start each process.
    • web = "go run main.go": This is the command to start your Go application.
    • nginx = "nginx -g 'daemon off;'" : This command starts Nginx in the foreground, which is crucial for containerized environments. If Nginx were to daemonize, the process would exit, and Fly.io would consider the container stopped.

Now, let’s consider the Dockerfile for this scenario. It would need to install Nginx and copy your Go application.

FROM golang:1.20 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main .

FROM debian:bullseye-slim

RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/main /app/main

# Create a directory for static assets and mount a volume
RUN mkdir -p /var/www/html
VOLUME /var/www/html

# Configure Nginx to serve from the shared volume
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

CMD ["sh", "-c", "nginx -g 'daemon off;' & /app/main"]

And the nginx.conf might look like this:

worker_processes 1;

events {
    worker_connections 1024;
}

http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    server {
        listen 80 default_server;
        server_name localhost;

        root /var/www/html;
        index index.html index.htm;

        location / {
            try_files $uri $uri/ =404;
        }
    }
}

In this Dockerfile:

  • We use a multi-stage build to compile the Go app first.
  • The second stage starts from a slim Debian image and installs Nginx.
  • COPY --from=builder /app/main /app/main brings the compiled Go binary into the final image.
  • VOLUME /var/www/html declares that this directory should be treated as a volume, which Fly.io can then manage and potentially share across VMs if needed (though in this sidecar setup, it’s primarily for local access within the VM).
  • COPY nginx.conf /etc/nginx/nginx.conf replaces the default Nginx configuration with our custom one.
  • The CMD is crucial. It starts Nginx in the foreground (nginx -g 'daemon off;') and then, using &, forks the Go application to run in the background (/app/main). Fly.io’s CMD or ENTRYPOINT is executed as the primary command. If this command exits, the container is considered stopped. By running both in a shell script, we ensure that if one process dies, the shell script itself doesn’t exit immediately, allowing Fly.io to manage the lifecycle based on its own health checks.

However, the fly.toml approach is generally preferred because it gives Fly.io more direct control over process management. The [processes] section in fly.toml is what tells the Fly agent inside the VM how to start and monitor these processes. If you define web and nginx in [[services.processes]], the Fly agent will start them using the commands defined in [processes].

The mental model here is that a Fly.io VM isn’t just running one container image; it’s running a specific set of processes managed by the Fly agent. The fly.toml is your directive to that agent. When you define multiple processes in [[services]], Fly.io ensures they are all started and kept alive. If any process defined in [processes] that is also listed in [[services.processes]] fails, Fly.io will attempt to restart it. If the primary process (the one associated with the service port) fails repeatedly, the entire VM may be restarted.

The surprising truth about running sidecar processes on Fly.io is that the CMD or ENTRYPOINT in your Dockerfile is largely ignored when you define processes in fly.toml. The Fly agent takes over process management based on your fly.toml. Your Dockerfile just needs to provide the binaries and necessary files for all the processes you intend to run.

The next concept you’ll likely explore is how these shared processes can communicate. Since they run in the same VM, they can communicate via localhost on their respective ports, or through Unix domain sockets, making inter-process communication very efficient.

Want structured learning?

Take the full Fly-io course →