Building a SPA with Fly.io and Caddy

Published 29.07.2022 • Last modified 01.03.2024

Fly.io is a cloud service that can be used to easily deploy fullstack applications on the web. You only pay for what you use, and servers can be scaled up and down easily. Fly.io supports lots of frameworks and Docker images, which will come in handy. I’ll show how you can deploy a Caddy web server serving a single page application with a Go backend.

Getting started #

First, you will need to install flyctl and create a fly.io account. Once you have logged in with flyctl, create a new application:

> cd project_name
> flyctl launch

It will ask you for the app name and region, then create a fly.toml file looking something like this:

app = "project_name"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[env]

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8080
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

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

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

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

This configuration will automatically upgrade http connections to https, which is great. We can leave this config for now, and focus on the frontend and backend. Our project structure will look like this:

project_name
├───/client
├───/server
└fly.toml

Setting up the client #

I’m using vue.js with Vite for the frontend, but you can use any framework for this (or no framework at all).

npm init vue@latest with project name “client” The client can then be built into html/css/js assets with npm run build. It will put the output in /dist, which we can use when building the Docker image.

The advantage of Vite is that we can really easily proxy requests to the backend if we’re developing the application locally. This means that requests to "localhost:1234/api/hello" will go to "localhost:3300/api/hello"

// in vite.config.ts
export default defineConfig({
  ...
  
  server: {
    proxy: {
      '/api': {
        target: "http://localhost:3300",
        changeOrigin: true,
        secure: false,
      }
    }
  }
})

Setting up the server #

For the backend I’ll be using Gin, which is a REST framework for Go.

> mkdir server
> cd server
> go mod init aarol.dev/server
> go get -u github.com/gin-gonic/gin

main.go will look like this:

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	api := r.Group("/api")

	api.GET("/hello", func(c *gin.Context) {
		c.String(http.StatusOK, "world")
	})

	r.Run(":3300")
}

This will just send back “world” when navigating to "localhost:3300/api/hello". You don’t have to use Go for the backend, but the single executable that go produces makes building the docker image easier.

Configuring Caddy #

Caddy is a fast web server that has fancy features like automatic HTTPS and an easy configuration language. We can use Caddy to serve our SPA and proxy API requests to the backend.

Add a Caddyfile at the project root. It should look something like this:

http://icy-glitter-5928.fly.dev {
	# /api requests get proxied to the backend server
	handle /api/* {
		reverse_proxy localhost:3300
	}

	handle {
		root * /usr/share/caddy
		# requests with Accept-Encoding get automatically gzipped
		encode gzip
		# try to serve the file, if it doesn't exist, then serve index.html
		try_files {path} /index.html
		file_server
	}
}

The address (http://icy-glitter-5928.fly.dev in this case) will need to match exactly with your website address. We won’t be using Caddy’s automatic HTTPS here, as fly.io manages that for us already. Notice that we aren’t specifying any ports here. By default, ports 80, 443 and 2019 (admin portal) will be opened. Because all traffic to our app will be http only, we need to change the port to reflect that in our fly.toml.

...

[[services]]
  http_checks = []
  internal_port = 80
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  
	  ...

Building the Docker image #

This is our Dockerfile:

FROM golang:1.18 as builder

COPY /server /src/server
WORKDIR /src/server

RUN CGO_ENABLED=0 GOOS=linux go build -a -o /usr/local/bin/server .

FROM caddy:2.5.2-alpine

RUN apk add bash

COPY Caddyfile /etc/caddy/Caddyfile

COPY client/dist /usr/share/caddy
COPY --from=builder /usr/local/bin/server /usr/local/bin/server

COPY start.sh /start.sh
CMD /start.sh

It builds the server in a build stage, then copies the binary to an image based on caddy:alpine. The Caddyfile is copied to /etc/caddy/Caddyfile, and the client build output is copied to /usr/share/caddy.

Start.sh is a simple bash script, which starts Caddy in the background and runs the server binary:

#!/bin/bash
set -e

caddy start --config /etc/caddy/Caddyfile --adapter caddyfile
/usr/local/bin/server

The last step is deploying the program.

> cd client
> npm run build
> cd .. 
> flyctl deploy

If deployment is successful, your website should be visitable in the browser. Going to "/api/hello" should return back “hello”.

Notes #