# 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.

- Skill: `archetype2142/deploy-vite-app` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add archetype2142/deploy-vite-app`
- Raw SKILL.md: https://api.skillmd.com/api/skills/archetype2142/deploy-vite-app/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: archetype2142 (https://skillmd.com/u/archetype2142)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/archetype2142/deploy-vite-app

---


# 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:

```bash
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+, 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`, sometimes `https`),
- 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:

```bash
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) and `COPY 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:

```bash
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` (or `remote "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.

