{"slug":"how-to-deploy-a-vite-ssr-app-to-a-vps-without-docker","url":"https://tako.sh/blog/how-to-deploy-a-vite-ssr-app-to-a-vps-without-docker/","canonical":"https://tako.sh/blog/how-to-deploy-a-vite-ssr-app-to-a-vps-without-docker/","title":"How to Deploy a Vite SSR App to a VPS Without Docker","date":"2026-05-06T07:43","description":"Build a Vite React SSR app, wrap the server bundle with tako.sh/vite, ship dist/client assets, and deploy to a VPS as a native process.","author":null,"image":"cdeff33db1e3","imageAlt":null,"headings":[{"depth":2,"slug":"step-1---create-the-vite-app","text":"Step 1 - Create the Vite app"},{"depth":2,"slug":"step-2---add-the-tako-vite-plugin","text":"Step 2 - Add the Tako Vite plugin"},{"depth":2,"slug":"step-3---tell-tako-what-to-deploy","text":"Step 3 - Tell Tako what to deploy"},{"depth":2,"slug":"step-4---deploy-to-the-vps","text":"Step 4 - Deploy to the VPS"}],"markdown":"Vite's SSR story is refreshingly direct: make a browser build, make a server build, and run the server entry in production. Most tutorials finish by wrapping that in Express, a Dockerfile, or a hosted platform adapter.\n\nYou do not need the container layer for that. With [Tako](/docs/), the server bundle can run as a normal Node or Bun process on a VPS, while Tako handles HTTPS, routing, health checks, static assets, and [zero-downtime deploys](/blog/zero-downtime-deploys-without-a-container-in-sight/).\n\nThis walkthrough uses a plain Vite React SSR app, the `tako.sh/vite` plugin, and one explicit `tako.toml`. No Dockerfile, no image registry, no Nginx side quest.\n\n## Step 1 - Create the Vite app\n\nStart with the regular Vite React template:\n\n```bash\nnpm create vite@latest vite-ssr-on-tako -- --template react-ts\ncd vite-ssr-on-tako\nnpm install\nnpm install tako.sh\n```\n\nThe default template is a client-side app. To make it SSR-shaped, change `index.html` so React has a server-rendered outlet:\n\n```html\n<div id=\"root\"><!--ssr-outlet--></div>\n<script type=\"module\" src=\"/src/main.tsx\"></script>\n```\n\nThen make the browser entry hydrate instead of creating a fresh client-only tree:\n\n```tsx\n// src/main.tsx\nimport { StrictMode } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport App from \"./App\";\nimport \"./index.css\";\n\nhydrateRoot(\n  document.getElementById(\"root\")!,\n  <StrictMode>\n    <App />\n  </StrictMode>,\n);\n```\n\nNow add the server entry. The important part is the export: `tako.sh/vite` expects the compiled server module to expose a fetch handler, either as a default function, a default object with `.fetch`, or a named `fetch` export.\n\n```tsx\n// src/entry-server.tsx\nimport { readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { renderToString } from \"react-dom/server\";\nimport App from \"./App\";\n\nconst serverDir = path.dirname(fileURLToPath(import.meta.url));\nconst templatePath = path.resolve(serverDir, \"../client/index.html\");\n\nexport default async function fetch(request: Request): Promise<Response> {\n  const url = new URL(request.url);\n  const template = await readFile(templatePath, \"utf8\");\n  const appHtml = renderToString(<App />);\n\n  const html = template\n    .replace(\"<!--ssr-outlet-->\", appHtml)\n    .replace(\"<title>Vite + React + TS</title>\", `<title>${url.pathname} - Vite SSR</title>`);\n\n  return new Response(html, {\n    headers: { \"content-type\": \"text/html; charset=utf-8\" },\n  });\n}\n```\n\nThis is deliberately small. If your app uses React Router, TanStack Router, or another SSR router, pass `url.pathname` into that router instead of rendering the same `<App />` for every path. The deployment shape stays the same: `Request` in, `Response` out. That [fetch handler pattern](/blog/the-fetch-handler-pattern/) is the boundary Tako runs.\n\n## Step 2 - Add the Tako Vite plugin\n\nUpdate `vite.config.ts`:\n\n```ts\nimport react from \"@vitejs/plugin-react\";\nimport { defineConfig } from \"vite\";\nimport { tako } from \"tako.sh/vite\";\n\nexport default defineConfig({\n  plugins: [react(), tako()],\n});\n```\n\nOn the production server build, the plugin writes a wrapper next to the compiled server bundle: `dist/server/tako-entry.mjs`. That wrapper imports your compiled Vite SSR entry, finds the fetch handler, adds Tako's internal status endpoint, and re-exports one default fetch handler for the runtime to launch.\n\nIt also matters during `tako dev`. Vite normally prints a localhost URL and calls it a day. Tako waits for a readiness signal on file descriptor 4, then routes local HTTPS traffic through the dev proxy. The plugin binds Vite to loopback, accepts `.test` and `.tako.test` hosts, and reports the bound port back to the parent process so `tako dev` knows the app is actually ready. The [development docs](/docs/development/) cover the local proxy flow in more detail.\n\nNow replace the package scripts with the two-build SSR shape Vite documents for production:\n\n```json\n{\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"npm run build:client && npm run build:server\",\n    \"build:client\": \"vite build --outDir dist/client\",\n    \"build:server\": \"vite build --outDir dist/server --ssr src/entry-server.tsx\",\n    \"preview\": \"vite preview\"\n  }\n}\n```\n\nThe client build creates `dist/client/index.html` and `/assets/...` files. The server build creates `dist/server/entry-server.js`, and the Tako plugin adds `dist/server/tako-entry.mjs`.\n\n| Output                        | What uses it                                      |\n| ----------------------------- | ------------------------------------------------- |\n| `dist/client/index.html`      | The server entry reads it as the HTML template    |\n| `dist/client/assets/*`        | Tako serves these from deployed `public/assets/*` |\n| `dist/server/entry-server.js` | The compiled Vite SSR module                      |\n| `dist/server/tako-entry.mjs`  | The entrypoint Tako launches                      |\n\nRun it once:\n\n```bash\nnpm run build\nls dist/server/tako-entry.mjs\n```\n\nIf that file exists, Vite and Tako agree on the server entry.\n\n## Step 3 - Tell Tako what to deploy\n\nInstall the CLI and initialize the project:\n\n```bash\ncurl -fsSL https://tako.sh/install.sh | sh\ntako init\n```\n\nFor a custom Vite SSR app, keep the generated config explicit. The plain `vite` preset supplies the Vite dev command, but your SSR entry and client asset directory are project-specific:\n\n```toml\nname = \"vite-ssr-on-tako\"\nruntime = \"node\"\nruntime_version = \"22.x\"\npackage_manager = \"npm\"\npreset = \"vite\"\nmain = \"dist/server/tako-entry.mjs\"\nassets = [\"dist/client\"]\n\n[envs.production]\nroute = \"vite.example.com\"\nservers = [\"prod\"]\n```\n\nTwo lines do most of the SSR work:\n\n| Config                                | Why it matters                                                                 |\n| ------------------------------------- | ------------------------------------------------------------------------------ |\n| `main = \"dist/server/tako-entry.mjs\"` | Launch the generated wrapper, not the raw Vite output                          |\n| `assets = [\"dist/client\"]`            | Merge the client build into deployed `public/` so `/assets/*.js` resolves fast |\n\nDuring deploy, Tako runs the build locally, merges configured asset directories into the artifact's `public/` directory, verifies `main`, packages the result, and uploads it over SFTP. On the server, static requests with file extensions are served directly from `public/` when present; everything else goes to your SSR process. The [Tako config docs](/docs/tako-toml/) and [deployment guide](/docs/deployment/) have the full field reference.\n\n## Step 4 - Deploy to the VPS\n\nSet up the server once. On the VPS:\n\n```bash\nsudo sh -c \"$(curl -fsSL https://tako.sh/install-server.sh)\"\n```\n\nOn your laptop, register it:\n\n```bash\ntako servers add 203.0.113.10 --name prod\n```\n\nPoint `vite.example.com` at the VPS IP, then deploy:\n\n```bash\ntako deploy\n```\n\nConfirm the production prompt and watch the task tree:\n\n```text\nConnecting     ✓\nBuilding       ✓\nDeploying to prod\n  Uploading    ✓\n  Preparing    ✓\n  Starting     ✓\n\n  https://vite.example.com/\n```\n\nYour Vite SSR app is now running as a native Node process behind Pingora, with a real Let's Encrypt certificate. No container runtime is involved.\n\n```d2\ndirection: right\n\nlocal: \"Local build\" {\n  client: \"vite build\\n dist/client\" {\n    style.fill: \"#FFF9F4\"\n  }\n  server: \"vite build --ssr\\n dist/server\" {\n    style.fill: \"#FFF9F4\"\n  }\n  wrapper: \"tako-entry.mjs\" {\n    style.fill: \"#9BC4B6\"\n  }\n  server -> wrapper: \"wrap fetch\"\n}\n\nartifact: \".tar.zst\\nartifact\" {\n  style.fill: \"#FFF9F4\"\n  style.stroke: \"#2F2A44\"\n}\n\nvps: \"VPS\" {\n  proxy: \"Pingora\\nTLS + routing\" {\n    style.fill: \"#E88783\"\n  }\n  public: \"public/assets\" {\n    style.fill: \"#FFF9F4\"\n  }\n  node: \"Node process\\nSSR fetch handler\" {\n    style.fill: \"#9BC4B6\"\n  }\n  proxy -> public: \"static files\"\n  proxy -> node: \"HTML requests\"\n}\n\nlocal.wrapper -> artifact: \"package\"\nlocal.client -> artifact: \"assets\"\nartifact -> vps: \"SFTP\"\n```\n\nThe request path is simple. `/assets/main-abc123.js` is a static file, so Tako serves it directly from the deployed `public/` directory. `/pricing`, `/dashboard`, or `/` goes to the Node process, which imports `dist/server/tako-entry.mjs`, calls your SSR fetch handler, and returns HTML.\n\nThat separation is the whole trick. Vite still does the bundling. React still does the rendering. Tako supplies the deployment boundary around them: native process startup, health checks, static file serving, TLS, and rolling replacement. When you need secrets next, add them with [`tako secrets`](/blog/secrets-without-env-files/). When you want to see every CLI shape, the [CLI reference](/docs/cli/) is the map."}