Deploy a Vite/React app to a Docker + Traefik server
This skill takes a local frontend project and gets it live at
https://<subdomain>.<root-domain> on the user's own VPS, where <root-domain>
is whatever they set during setup. The server runs Docker with a Traefik reverse
proxy that handles routing and Let's Encrypt TLS. DNS is managed in Cloudflare.
The whole thing is one SSH-driven flow: build the app into a container, ship it,
run it behind Traefik, and point a subdomain at it.
The reason this is a skill rather than ad-hoc steps is that getting a deploy right means matching the server's existing conventions — the Traefik network name, the entrypoint names, the certresolver, where apps live on disk. Guessing those wrong produces a container that runs but never receives traffic, or a cert that never issues. So the core discipline here is: discover the server's setup first, then conform to it, rather than imposing a config that looks right in isolation.
Configuration
All site-specific settings — SSH host/user/key, root domain, server IP, the
Cloudflare token, the reserved-subdomain list, and any pinned Traefik values —
live in ~/.config/deploy-vite-app/config.env, written by install.sh (see
README). Nothing is hardcoded to a particular domain or server: the repo ships
with no real values, which is what makes it safe to publish and portable across
machines and across Claude Code / Cursor. scripts/config.sh loads that file
(environment variables override it) and every script reads from it. If essential
values are missing, the scripts stop with a clear "run install.sh" message rather
than guessing.
To run a one-off remote command, source the config and use the helper:
source scripts/config.sh
remote "docker ps" # runs over SSH with the configured key/user/host
If the user's request mentions different values, prefer those for the session.
Reserved subdomains
The user can mark certain subdomains as off-limits (set during install.sh,
stored as RESERVED_SUBDOMAINS) — typically ones already pointing at live
services, since deploying over them would create a conflicting DNS record or
Traefik router and break something.
Always validate the requested subdomain with scripts/check_subdomain.py <subdomain> before doing anything else. It enforces the configured blocklist
plus DNS label rules (lowercase, alphanumeric + hyphens, no leading/trailing
hyphen) and prints the full hostname when valid. If the user asks for a reserved
one, stop and ask them to pick another. If they don't specify a subdomain at all,
suggest one derived from the project's directory or package.json name and
confirm it before proceeding.
The deploy flow
Work through these in order. The early steps are cheap and catch the failures that are expensive to debug later (a broken build, a taken subdomain, a mismatched Traefik network).
1. Locate and sanity-check the project
Confirm you're pointed at a real Vite project: there should be a package.json
with a build script and (usually) a vite dependency. If you're unsure which
directory the user means, ask. Read package.json to learn the project name and
whether it's a plain static build (the common case) or something that needs a
running Node process (SSR, an API route, serve-style server). Most of the time
it's static — npm run build emits a dist/ of HTML/JS/CSS.
2. Choose and validate the subdomain
Run python3 scripts/check_subdomain.py <subdomain>. It exits non-zero and
explains why if the name is reserved or malformed, and prints the full hostname
(<subdomain>.<root-domain>) when valid. Confirm that hostname with the user.
3. Discover the server's Traefik setup — do not assume
If the server hasn't been provisioned yet, get it ready first — and prefer the no-root path:
- Docker is installed and the deploy user can run it, but there's no Traefik:
run
scripts/bootstrap_traefik.sh. No root needed. It creates the shared network, starts Traefik v3.7 (pinned — older versions break against Docker 29+, seereferences/traefik.md) under the deploy user's$HOME/traefik, and writesTRAEFIK_NETWORK/entrypoint/certresolver into config.deploy.shalso offers to run this automatically when it finds no Traefik. - Fresh box (no Docker, or the user lacks sudo): run
scripts/server_setup.sh(needs root once). It enables passwordless sudo, installs Docker, fixes the containerd content store, then callsbootstrap_traefik.sh.
A quick way to tell what's there: remote "docker ps" either errors (Docker/sudo
not ready → server_setup.sh) or runs but shows no traefik container
(→ bootstrap_traefik.sh).
Otherwise, SSH in and look at how Traefik is actually configured, because the
labels you generate must match it exactly. Read references/traefik.md for what
to look for and how to interpret it. In short, you need three values:
- the Docker network Traefik shares with app containers (often
traefik,proxy, ordokploy-network), - the websecure entrypoint name (usually
websecure, sometimeshttps), - the certresolver name for Let's Encrypt (e.g.
letsencrypt,myresolver).
The fastest way to get all three right is to copy them from an app that's
already deployed and working — inspect a running container's labels with
docker inspect. references/traefik.md gives the exact commands.
4. Build locally (smallest footprint on the VPS)
Build the app on the local machine, not the server. The VPS is expected to host
dozens of apps on a tiny box, so the deploy is designed to never run a Node
toolchain there — only the finished dist/ is shipped. Building locally also
fails fast on a broken project:
npm ci && npm run build # or npm install if there's no package-lock.json
Confirm a dist/ (or the project's configured Vite outDir) appears. If the
output dir differs, pass it to the deploy script with --dist <dir>.
5. Generate the deploy artifacts
deploy.sh renders two files from assets/ into a staging bundle alongside the
built dist/. references/templates.md explains them and the footprint
reasoning in detail.
Dockerfile—FROM static-web-server:2(~5 MB) andCOPY dist/. Serves on port 80 with--page-fallback /public/index.html, i.e. SPA fallback so client-side routes like/aboutreturn 200 on refresh instead of 404. This is the single most common thing people forget with React Router.docker-compose.yml— one service on the Traefik network with labels forHost(<sub>.<root-domain>), the websecure entrypoint, and the certresolver discovered in step 3.
Why this image: the runtime is a single static binary on a scratch base. Every
app pins the same base tag, so Docker stores that ~5 MB layer once and each
extra app costs only its own files — both disk and RAM stay tiny across dozens of
apps. Don't vary the base tag per app or you lose that sharing.
The compose project/container is named app-<sub> so redeploys replace in place
instead of piling up duplicates.
If a project genuinely needs a running Node server (SSR, an API route) rather
than static files, see variants A–C in references/templates.md for the
alternate Dockerfile and the one label that changes (the service port). Keep the
static default for the common case — the Node runtime image is much larger.
6. Create the Cloudflare DNS record
Run scripts/cloudflare_dns.sh <subdomain>. It looks up the root domain's zone
ID, then creates or updates an A record for <subdomain> pointing at the
configured server IP. It needs a CLOUDFLARE_API_TOKEN with Zone:DNS:Edit on
that zone (stored in config by install.sh). If the token is missing, the
script prints exactly how to create one — relay that to the user and pause until
they provide it.
By default the record is created DNS-only (not proxied / grey cloud). This
is deliberate: it lets Traefik complete the Let's Encrypt HTTP-01 challenge and
own the TLS cert directly, which is the simplest setup that reliably works. If
the user specifically wants Cloudflare's proxy (orange cloud) in front, pass
--proxied — but mention that TLS then terminates at Cloudflare and the
Traefik/Let's Encrypt setup may need its SSL mode set to "Full". Default to
DNS-only unless they ask otherwise.
TLS is normally automatic. The compose labels already request a Let's Encrypt
cert via Traefik's certresolver, and Traefik issues + renews it on the first
HTTPS request — you don't run certbot in the common case. If the user wants one
wildcard *.<root-domain> cert for every subdomain (cleaner at scale, and
proxy-compatible), or Traefik isn't configured for ACME, read references/tls.md:
it covers the wildcard-via-Traefik-DNS-01 setup and a certbot fallback
(scripts/certbot_wildcard.sh) with automatic renewal.
7. Ship and run
Ship only the staging bundle (the built dist/, the rendered Dockerfile, and
docker-compose.yml) to /opt/apps/<sub> on the server, then build and start.
The transfer is tiny and the server-side docker build just copies static files
into the ~5 MB base — no Node, negligible CPU/RAM. scripts/deploy.sh does steps
4–8 end to end:
scripts/deploy.sh --app <project-dir> --sub <subdomain> [--proxied] [--dist <dir>]
Read the script before running so you can adjust the app path if step 3 revealed a different server layout.
8. Verify it's actually live
deploy.sh does this automatically (it polls the URL and prints a result
banner), but when checking by hand a container that's "up" isn't the same as a
working site:
apps.sh list(orremote "docker compose -f <appdir>/docker-compose.yml ps") shows the container running.curl -sS -o /dev/null -w '%{http_code}' https://<sub>.<root-domain>returns- The very first request after deploy may take a few seconds while Traefik obtains the certificate — if you get a TLS error immediately, wait ~30s and retry before treating it as a failure.
- Spot-check a client-side route (e.g.
.../some/path) returns 200 too, confirming the SPA fallback works.
Report the live URL to the user once it's verified. deploy.sh's terminal UI
shows each step (✓/·/!) and ends with a boxed banner containing the live URL,
the image size, and the dist size — relay that outcome to the user.
Managing deployed apps (scripts/apps.sh)
When the user wants to see what's deployed, take something down, or free space on
a full VPS, use apps.sh rather than ad-hoc docker commands:
apps.sh list— table of every app: subdomain, container status, image size, disk usage, and URL.apps.sh space— filesystem +docker system df+ the largest app directories, so you can decide what to remove when disk is tight.apps.sh down <sub>— stop and remove the container but keep its files and image (a redeploy brings it straight back). Good for temporarily freeing RAM.apps.sh remove <sub> [--dns]— fully remove the container, its image, and the app directory to reclaim disk.--dnsalso deletes the Cloudflare record. Confirm with the user before removing, and especially before--dns.apps.sh prune— drop dangling images and build cache; a safe first move when space is low before removing any actual app.
Redeploy / update: just re-run deploy.sh for the same subdomain. The stable
app-<sub> project name means it rebuilds and replaces in place; the DNS record
already exists and is left as-is.
When something doesn't work
- 502 / 404 from Traefik: the container isn't on Traefik's network, or the
service port label doesn't match the port the app listens on (80 for the SWS
image). Re-check step 3's network name and the
loadbalancer.server.portlabel. - Cert never issues / TLS errors persist: the DNS record may be proxied when it should be DNS-only, or the certresolver name doesn't match Traefik's config. Verify against a working app's labels.
- Build fails on the server but passed locally: usually a case-sensitivity
issue (Linux filesystem vs macOS) in an import path, or a dependency missing
from
package.json. Read the build log fromdocker compose ... up --build.
references/traefik.md and references/templates.md go deeper on each of these.