How to Deploy a React Project with Static Files and Docker in Dokploy
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
BrowserRouterand neverHashRouter— you want clean URLs like/dashboard, not/#/dashboard. - Every
envvariable that ends up inside your bundle (anything prefixedVITE_,REACT_APP_, orNEXT_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.
Dockerfileordocker/Dockerfile.production - Docker Context Path — where the build context lives;
.for the repo root - Docker Build Stage — the target stage, e.g.
builderfor 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 —
Staticfor SPAs,dockerfilewhen 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
80for static, and matches yourEXPOSEfor 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.