Back to Blog
By Shayne Technology
Hosting a SPA on fly.io
minimal, production-ready way to serve a SPA on Fly.io using Caddy


Below is a minimal, production-ready way to serve a SPA on Fly.io using Caddy ( It supports SPA history routing via a fallback file )
works with any applications that build to /dist
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
ARG NODE_VERSION=22.17.0
FROM node:${NODE_VERSION}-slim as base
LABEL fly_launch_runtime="NodeJS"
# NodeJS app lives here
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
# Throw-away build stage to reduce size of final image
FROM base as build
# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install -y python-is-python3 pkg-config build-essential
# Install node modules
COPY --link package.json .
RUN npm install --production=false
# Copy application code
COPY --link . .
# Build application
RUN npm run build
# Remove development dependencies
RUN npm prune --production
FROM caddy:2.5.2-alpine
RUN apk add bash
COPY Caddyfile /etc/caddy/Caddyfile
RUN caddy fmt /etc/caddy/Caddyfile --overwrite
COPY --from=build /app/dist /usr/share/caddy
# Expose port
EXPOSE 8043
# Start with goStatic and fallback to index.html for SPA routing
# CMD ["goStatic", "--fallback", "/srv/http/index.html"]
CMD ["caddy", "start", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
Key bits: files go to /srv/http, default port is 8043, and -fallback /index.html handles SPA routes.
After fly launch (choose “Dockerfile”), adjust fly.toml so Fly forwards public 80/443 to internal 8043:
app = "your-spa-app"
primary_region = "syd"
[build]
# Uses the Dockerfile in the repo
[[services]]
internal_port = 8043
processes = ["app"]
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
andlers = ["tls", "http"]
port = 443
# Optional: health check hits goStatic’s /health when enabled
[services.concurrency]
type = "connections"
hard_limit = 200
soft_limit = 100
[[services.http_checks]]
interval = "10s"
timeout = "2s"
method = "GET"
path = "/health"
protocol = "http"
grace_period = "20s":8043 {
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
}
}# one-timefly auth signup # or fly auth loginfly launch # accept Dockerfile; pick region; don’t create a DB# thenfly deployfly open