Building a SPA with Fly.io and Caddy
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 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 #
- Access logs for caddy aren’t visible in flyctl logs. You can log to file with Caddy or use something from this page
- Caddy’s docker image store TLS certificates and other important assets in the “/data” directory. It’s expected to be persistent, which it isn’t currently. I don’t think this configuration requires it, but you can create a volume for it
- The project is available on github