deploy-vite-app
NewDeploy a Vite + React app (or any static/SPA frontend) to the user's own Docker + Traefik server over SSH, on a Cloudflare-managed subdomain with automatic HTTPS. Use this skill whenever the user wants to deploy, ship, publish, push live, or host a Vite/React/frontend project on their server — e.g. "deploy this app", "put this on my server", "ship the dashboard to a subdomain", "host this on a subdomain", "make this live". Also use it to redeploy/update an already-deployed app, list what's deployed, take an app down, or free up disk space on the server. Trigger even when the user doesn't say "Docker", "Traefik", or "Cloudflare" explicitly — if they have a frontend project and want it on the internet on their box, this is the skill.
Overview
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/hostIf 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+, see references/traefik.md) under the deploy user's $HOME/traefik, and writes TRAEFIK_NETWORK/entrypoint/certresolver into config. deploy.sh also 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 calls bootstrap_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, or dokploy-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.jsonConfirm 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 /about return 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 for
Host(<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
200. 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. --dns also 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.port label.
- •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 from docker compose ... up --build.
references/traefik.md and references/templates.md go deeper on each of these.
Install & Usage
mkdir -p .claude/skillsmkdir -p .claude/skills && curl -o .claude/skills/deploy-vite-app.md https://raw.githubusercontent.com/archetype2142/deploy-skill/main/SKILL.md/deploy-vite-appFrequently Asked Questions
What is deploy-vite-app?
Deploy a Vite + React app (or any static/SPA frontend) to the user's own Docker + Traefik server over SSH, on a Cloudflare-managed subdomain with automatic HTTPS. Use this skill whenever the user wants to deploy, ship, publish, push live, or host a Vite/React/frontend project on their server — e.g. "deploy this app", "put this on my server", "ship the dashboard to a subdomain", "host this on a subdomain", "make this live". Also use it to redeploy/update an already-deployed app, list what's deployed, take an app down, or free up disk space on the server. Trigger even when the user doesn't say "Docker", "Traefik", or "Cloudflare" explicitly — if they have a frontend project and want it on the internet on their box, this is the skill.
How to install deploy-vite-app?
To install deploy-vite-app, create the .claude/skills directory in your project, then run the curl command to download the skill file. Once installed, invoke it in Claude Code with /deploy-vite-app.
What is deploy-vite-app best for?
deploy-vite-app is a community categorized under Documentation. It is designed for: deployment, frontend. Created by archetype2142.