How to Host a React and Node.js App using Caddy and Docker

Caddy

Overview – Caddy, Docker, React, Node.js

In this blog post, I’m going to share how, you can setup a reverse proxy to host a React and Node.js app from a single server – all while having automatic SSL. Here are the technologies used:

  • Docker
  • Caddy
  • A React front-end
  • A Node.js back-end

Hosting?

You’ve built your React front-end and a Node.js back-end. It all works beautifully using Vite‘s local hosting options. You’d now like embark on hosting this publicly and ensure you have SSL certificates – which are automatically renewed.
This means, you’d need to solve for a few aspects:

  1. Hosting the front-end application
  2. Hosting the back-end server
  3. Ensuring the above have SSL certs

Cloud hosting solutions are ubiquitous these days – DigitalOcean’s AppPlatform, Netlify, Vercel, etc. These are convenient and provide a relatively quick way to host your services, however, there is an alternative – hosting these yourself on a virtual private server (VPS).
The self-hosting route takes a bit more effort and your application may not easily scale, but for the project I had in mind, these weren’t a big enough blocker. Hence, if I opted for the latter option.

Self-hosting

Solving the reverse proxy problem

Why do I need a reverse proxy?

A reverse proxy is the system/tool that can redirect all requests that are made to a single public IP address to specific applications internally.
For example:
Your VPS has a public IP of 172.14.1.129. When you setup a DNS record to point to that public IP, all traffic will be routed there. There’s no distinguishing between the front-end and back-end at this point. Herein lies the value of a reverse proxy. We can setup the following DNS records:

  • Front-end (React): myapp.com -> 172.14.1.129
  • Back-end (Node.js): api.myapp.com -> 172.14.1.129

Then, the reverse proxy will do the following:

  • Front-end (React): myapp.com -> 172.14.1.129 -> Reverse Proxy -> Redirect to internal React app IP address
  • Back-end (Node.js): api.myapp.com -> 172.14.1.129 -> Reverse Proxy -> Redirect to internal Node.js app IP address

Choosing your reverse proxy tool

In a previous blog post, I used a tool called Traefik and it worked well for what I needed at the time.
Instead of using Traefik again, I stumbled upon Caddy. I was intrigued by the minimal configuration required so decided to give it a try.

Using Caddy

To use Caddy, you will need the following:

  • A Caddyfile where you define how you’d like your reverse proxy to function
  • Additional config within your docker-compose.yml file

The project directory is as follows:

  • root
    • client/
      • Dockerfile
    • Dockerfile (for server – Nodejs) – thinking about this more, I should have created a separate directory to house all Nodejs/server code
    • Caddyfile
    • docker-compose.yml

Here’s an example of the Caddyfile I used:
Caddyfile:

// Back-end API (Node.js)
api.example.com {
  reverse_proxy server:5000 { // server - referes to the docker service name
    header_down Strict-Transport-Security "max-age=31536000"
  }
}

// Front-end app (React)
example.com {
  root * /srv
  try_files {path} /index.html
  file_server
}

This docker-compose.yml file that has the following services:

  • server – the Node.js server
  • client – the React front-end
  • caddy – Caddy used for reverse proxy
version: "3.7"
services:
  client:
    container_name: client
    build:
      context: ./client
      dockerfile: Dockerfile
    networks:
      - network
    volumes:
      - app-dist:/app/client/dist # Mount the app-dist volume to the dist directory

  server:
    container_name: server
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5000:5000"
    restart: always
    environment:
      "NODE_ENV": "production"
    networks:
      - network
    # Run the caddy server
  caddy:
    image: caddy/caddy:2.7.5-alpine
    container_name: caddy-service
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - app-dist:/srv  # Mount the app-dist volume to the Caddy service
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - network
    depends_on:
      - server
      - client

volumes:
  app-dist:
  caddy_data:
  caddy_config:


networks:
  network:
    driver: bridge

Here is the Dockerfile used for the client (React) front-end:

# Use an official Node.js image as the base image FROM node:20 as builder # Set the working directory WORKDIR /app/client # Copy package.json and package-lock.json to the working directory COPY package*.json ./ # Install dependencies RUN npm install # Copy the entire client directory to the working directory COPY . ./ # Build the production version of the React app RUN npm run build # This is important as it's referenced in the docker-file VOLUME /app/client/dist

All that’s left for you to do is run docker-compose up -d and if all goes well, you’ll have both your React and Node.js app with automatic SSL certificates hosted on a single VPS.

Troubleshooting Caddy

Caddy SSL not working?

Did you delete caddy_data?

  • caddy_data contains your SSL certificates, to avoid rate limits from Lets Encrypt, avoid deleting this volume frequently

Reverse proxy isn’t working?

Check which port your service is running on

  • Make sure you’re using the docker internal service IP (e.g. for my server I’m using port 5000) in your Caddyfile

Ensure you’re using the service name

  • Make sure you’re referring to the docker service name in your Caddyfile. Notice I’m using server:5000

Conclusion

Using Caddy as a reverse proxy for a React and Node.js app, all hosted on a single server took minimal effort and it works! The automatic SSL that Caddy provides is invaluable for anyone self-hosting secure apps.