Deploying an Expo Web Application with Kamal

2025-03-09 19:10:41 -0300

Background

At Switch Dreams, a client approached us with the need to rebuild their website to improves the UI/UX, security and scalability. We decided to use Expo for the frontend, allowing a unified development experience across Android, iOS, and web. For the backend, we chose Rails, leveraging its reliability and productivity to streamline development while ensuring a solid foundation for future growth.

Everything was running smoothly until we reached the point of deploying the Expo web application in a way that aligned with our stack. While we could have opted for a static file hosting service, we chose Kamal to ensure better compatibility with our deployment workflow and streamline everything within a single pipeline.

The lack of resources on deploying a static website with Expo—especially using Kamal—motivated me to share this article.

Steps Overview

  • Dockerfile setup
  • Health Check workaround
  • Kamal configuration

Dockerfile

First, we need to understand the three Expo export options, as they significantly impact the Dockerfile structure. For Expo (SDK 50+), we can build the web app using three different output modes:

Expo Documentation - Publishing Websites

  • Single: The default option that exports the application as a standard SPA.
  • Server: Generates both client (HTML) and server files for a Node.js custom server. This option is unnecessary for our use case since we only need a client-side application and do not use API routes.
  • Static: Exports HTML for every route in the app directory. This is better for SEO but is not ideal for a private app. For now, SPA is the best choice.

Choosing the Serving Method

After selecting the Single output mode, we had to decide how to serve the static files. We opted for the most common approach: Nginx.

Dockerfile Configuration

FROM node:20.17.0 AS build

WORKDIR /app

ENV EXPO_PUBLIC_API_URL="API_URL"
ENV PORT=80

# Copy package files
COPY package.json yarn.lock ./

# Install dependencies
RUN yarn install

COPY . .

# Fix a known issue with NativeWind
RUN yarn tailwindcss -i ./src/global.css -o ./node_modules/.cache/nativewind/global.css.web.css && npx expo export -p web

FROM nginx:stable-alpine

WORKDIR /app

RUN rm -rf /usr/share/nginx/html

COPY --from=build /app/dist /usr/share/nginx/html
COPY config/nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Nginx Configuration

A key part of this setup is configuring Nginx properly. One critical fix was ensuring static files were served with the correct MIME types. Write this config inside: /config/nginx.conf

events {
    worker_connections 1024;
}

http {
    server {
      listen 80;
      server_name your-host;
      root /usr/share/nginx/html;
      index index.html;

      # Serve static files from Expo under the correct path
      location /_expo/static/ {
          include  /etc/nginx/mime.types;
          alias /usr/share/nginx/html/_expo/static/;
      }

      # Serve asset files
      location /assets/ {
          include  /etc/nginx/mime.types;
          alias /usr/share/nginx/html/assets/;
      }

      location / {
        try_files $uri $uri/ /index.html;
      }
    }
}

Health Check Workaround

As of Kamal version 2.3, health checks cannot be disabled. Since Kamal was initially designed for full-stack applications, it assumes that every app should have a health check.

For a client-side application, this requirement is often unnecessary. To work around this, we created a simple up page inside the app router.

// src/app/up.jsx
import {View} from "react-native";

const UpPage = () => <View className="flex-1 bg-green-500"/>;

export default UpPage;

Another possible solution would be configuring the health check inside the Nginx setup.

Kamal Configuration

The Kamal configuration follows the standard setup. Below is a complete example:

# config/deploy.yml

# Name of your application.
service: yourapp

# Container image.
image: switchdreams/yourapp

# Deploy to these servers.
servers:
  web:
    - SERVER_IP

# Enable SSL via Let's Encrypt.
proxy:
  ssl: true
  host: your-host
  # kamal-proxy connects to your container over port 80.
  # app_port: 3000

# Registry credentials.
registry:
  username: your-registry-username
  password:
    - KAMAL_REGISTRY_PASSWORD

# Builder setup.
builder:
  arch: amd64

# Environment variables.
env:
  clear:
    EXPO_PUBLIC_API_URL: your-host
    NODE_ENV: production
    PORT: 80

# SSH configuration.
ssh:
  user: root
  keys: [ "path_to_your_key" ]

Conclusion

By leveraging Kamal for deployment, we streamlined our process, kept our infrastructure cohesive, and gained greater control over our stack. Hopefully, this guide helps others facing similar challenges deploy their Expo Web applications more efficiently.