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
    Hosting a SPA on fly.io

    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

    1) Dockerfile (multi-stage)

    
    # 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. 

    2) fly.toml

    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"

    5) Caddyfile

    :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
       }
    }

    4) Deploy

    # one-time
    fly auth signup   # or fly auth login
    fly launch        # accept Dockerfile; pick region; don’t create a DB
    # then
    fly deploy
    fly open