How to Deploy a React Project with Static Files and Docker in Dokploy
DevOps

How to Deploy a React Project with Static Files and Docker in Dokploy

Sep 26, 202612 min read
DockerDokployDeploymentDevOpsReact

How to Deploy a React Project with Static Files and Docker in Dokploy

Shipping a React app is easy until you want to own the server. Dokploy is a self-hosted PaaS that gives you Vercel-style deployments on your own VPS — domains, SSL, logs, rollbacks, and a real UI, all on top of Docker and Traefik.

This guide covers the three ways to get a React project live on Dokploy, and honestly breaks down when each one is the right call.

What Dokploy actually is

Dokploy installs onto a single server (Hetzner, DigitalOcean, Contabo, a box in your office — anything with Docker). It sits in front of a Traefik reverse proxy and gives you a dashboard to attach domains, watch build logs, and redeploy on every push.

  • One server, many apps — each project gets its own container and its own domain.
  • Automatic SSL — Let's Encrypt certificates are provisioned and renewed without you touching a single certbot command.
  • Git-driven deploys — point it at a repo and branch, and it rebuilds on push.
  • Static hosting included — a dedicated build type that serves your compiled files with optimised Nginx.

Method 1: The Static build type

This is the shortest path for a pure client-side React SPA, and it is what I'd reach for first. Dokploy builds your app, then copies the contents of your publish directory into an Nginx-optimised image and serves it on port 80.

Step 1 — Make sure your build output is self-contained

A static deployment means there is no Node process at runtime. Two things follow from that, and both are non-negotiable:

  • Routing must be client-side. If you use React Router, use BrowserRouter and never HashRouter — you want clean URLs like /dashboard, not /#/dashboard.
  • Every env variable that ends up inside your bundle (anything prefixed VITE_, REACT_APP_, or NEXT_PUBLIC_) is baked in at build time. It is public. Never put a secret in one.

Step 2 — Create the application in Dokploy

From the dashboard: Create → Application, connect your Git provider, and pick the repo plus the branch you deploy from.

Step 3 — Set the build type and publish directory

In Build, change Build Type to Static, then point Root Directory at your publish directory. The value depends on your bundler:

  • Vite → dist
  • Create React App → build
  • Next.js static export → out
  • Astro → dist

Then enable the Static SPA option. This is the setting people miss, and it is the difference between a working app and a broken one. It tells Nginx to fall back to index.html for any path that doesn't match a real file, so a user hitting /blog/my-post directly gets your app instead of a 404.

Step 4 — Point your API at the deployed backend

Since secrets cannot live in the bundle, define the API base URL as a build variable under Environment:

VITE_API_URL=https://api.yourdomain.com

Remember that changing this triggers a rebuild, not a restart. Dokploy makes this explicit with separate build and runtime environment variable scopes — put anything the browser needs in the build scope, and anything only the server reads in the runtime scope.

Step 5 — Attach the domain

Go to Domains, add your hostname, and leave the port as 80. The Static build type serves on port 80 specifically, so a mismatch here is the most common cause of a blank page. Click the dice icon to auto-generate a Dokploy domain and confirm the app works before pointing a real hostname at it.

Click Deploy, watch the logs, and you should see the Nginx image build followed by a healthy container. SSL is handled for you.

Method 2: Nixpacks

If you don't want to think about it, leave the build type on the default nixpacks. Dokploy inspects your repo, detects the framework, installs dependencies, runs the build, and starts the app. Set a Publish Directory and it switches to serving the output with Nginx automatically — which makes Nixpacks a fine default for static apps too.

Nixpacks is genuinely the right choice for prototyping and for apps that need a Node runtime. It is the wrong choice in production for one specific reason, covered in Method 3.

Method 3: A hand-written Dockerfile

When you need control, bring your own Dockerfile. Dokploy exposes three fields for it:

  • Dockerfile Path (required) — e.g. Dockerfile or docker/Dockerfile.production
  • Docker Context Path — where the build context lives; . for the repo root
  • Docker Build Stage — the target stage, e.g. builder for a multi-stage build

For a Vite React SPA this is the shape I reach for — a multi-stage build that never ships node_modules to production:

# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Baked in at build time — public by definition
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

# ---- runtime ----
FROM nginx:alpine AS runtime

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

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

And the matching nginx.conf that makes client-side routing work. This is the part people forget, and it produces the classic "works on the homepage, 404 on refresh" bug:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Hashed build assets can be cached forever
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Everything else falls back to the SPA entry point
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Declare those in Dokploy as Dockerfile Path: Dockerfile, Docker Context Path: ., Docker Build Stage: runtime.

The production problem nobody warns you about

Here is the reason Dokploy's own production guide steers you away from server-side builds. Building a Node app consumes a lot of RAM and CPU — installing dependencies and running a bundler can spike hard enough to OOM-kill the Docker daemon. When that happens, every other application on the same server goes down with it. One heavy build becomes everyone's outage.

The fix is to move the build off your server. Build in CI, push the finished image to a registry, and let Dokploy do nothing but run it.

Build in GitHub Actions, ship the image

Add .github/workflows/deploy.yml to the repo:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: yourname/react-app:latest

Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN under Settings → Secrets → Actions. Use an access token, not your account password.

Then in Dokploy: General → Source Type → Docker, enter the image name yourname/react-app:latest, save, and hit Deploy. That's it — the server now only pulls and runs a prebuilt image, which takes seconds instead of minutes and uses almost no resources.

Because the image is already built, environment variables like VITE_API_URL become runtime config for the container, not build-time args. If your app reads config from the browser, switch to a /config.json fetched at startup or a runtime-injected <script> tag so you can change it without a rebuild.

Client-side routing vs. real static hosting

One more thing worth being explicit about: a "static" deployment does not mean the server knows your routes. Every URL is served the same index.html, and your React app reads window.location to decide what to render. This is fine for SPAs, and it's why the SPA fallback matters — but it also means the server cannot return genuinely different HTML per route, and search engines see the shell until JavaScript runs.

If you need per-route metadata, static rendering, or real 404 pages, use Next.js static export (next build with output: 'export') or Astro and set the publish directory to out or dist. Same Dokploy workflow, but the export produces one HTML file per route.

Deployment checklist

  • Build type matches the app — Static for SPAs, dockerfile when you want control.
  • Publish directory is correct for your bundler (dist / build / out).
  • Static SPA mode is on if you have client-side routes.
  • API URLs live in build-scope variables and contain no secrets.
  • Port is 80 for static, and matches your EXPOSE for Docker builds.
  • Production builds happen in CI, not on the Dokploy host.
  • Test the generated Dokploy domain before attaching your real hostname.

Wrapping up

For a plain React SPA, the Static build type is genuinely a five-minute job: set the publish directory, turn on SPA mode, add the domain, deploy. Reach for Nixpacks when you want zero configuration, and reach for a Dockerfile when you need caching headers, a custom server, or a non-standard runtime. And for anything you actually depend on, build in CI — your server should never be the machine that compiles your app.

Share this article

Back to all posts