Deploying a JavaScript App

Your app runs perfectly on localhost:3000. Now a stranger in another country needs to open it in their browser, quickly, over an encrypted connection, on your own domain — and it should stay up while you sleep. That gap between “works on my machine” and “works for everyone” is deployment, and it is mostly a set of decisions rather than a single command.

This article walks the whole path: picking where your app should run, turning source code into something a server can serve, keeping secrets out of your repo, controlling caches so users get fast pages and fresh updates, and pointing a real domain at it over HTTPS. By the end you should be able to deploy a site like this one without cargo-culting a tutorial.

Four places your app can live

Almost every hosting choice collapses into four shapes. They differ in one question: who runs code, and when.

Does a server run codeper request?noyesStatic hostingprebuilt files on a CDNNeed long-lived state?noyesServerless / edgefunctions, scale to zeroWant to own the OS?noyesContainersDocker on a PaaSVPSa raw Linux box
A rough decision tree. Start at the top and follow the first branch that matches your app. Most frontends land on the left; most APIs and databases pull you right.

Static hosting

If your app is HTML, CSS, and JavaScript that can be produced ahead of time — a marketing site, a docs site, a single-page app, a statically generated blog like this one — there is no server logic to run per request. You build once, and every visitor gets the same files. Those files live on a CDN: a fleet of servers spread across the globe that hand out copies from wherever is nearest the user.

This is the cheapest, fastest, and most durable option. There is no runtime to crash, no cold start, nothing to patch. Cloudflare Pages, Netlify, and Vercel all offer generous free tiers for exactly this. If you can get away with static, do — you can always add dynamic pieces later.

Serverless and edge functions

The moment you need code to run when a request arrives — reading a database, checking a session, calling a payment API — you need a server. Serverless is the lightest way to get one. You upload a function; the platform runs an instance only when traffic comes in and bills you for the milliseconds it executed. No traffic, no bill. This is how server-rendered pages (SSR) and API routes usually ship on Vercel, Netlify, and Cloudflare.

The catch is that functions are stateless and pay a cold start on the first request to a fresh instance. That full trade-off — isolates, cold starts, the edge, and the web-standard API shared across runtimes — has its own article: Edge & Serverless Runtimes. For deployment purposes, know that most frontend platforms turn your SSR routes into serverless functions automatically, so this often isn’t a separate decision at all.

Containers

Some apps don’t fit the serverless mold: a WebSocket server holding thousands of live connections, a background worker chewing through a queue, a process that needs a warm in-memory cache or a specific system library. For these you want a container — a Docker image bundling your app, its runtime, and its OS dependencies into one artifact that runs the same everywhere.

You hand that image to a platform-as-a-service that keeps it running. As of mid-2026 the common choices are:

  • Railway — detects your stack and builds without a Dockerfile in most cases; metered per second, Hobby plan around $5/month, no free tier.
  • Render — fixed instance pricing with a genuine free tier that sleeps when idle.
  • Fly.io — pay-as-you-go per second, usually needs a Dockerfile; the old free allowance ended in 2026, and a small always-on machine now runs a few dollars a month.

VPS

A virtual private server is a raw Linux box you rent — from Hetzner, DigitalOcean, a cloud provider — and configure yourself. You install Node, set up a reverse proxy, wire up TLS certificates, manage the firewall, and handle your own restarts and backups. Maximum control, maximum responsibility.

Reach for a VPS when you need something the managed platforms won’t give you — a particular kernel, cron jobs, a self-hosted database next to the app, or predictable flat pricing at scale. For a first deployment it is usually more sysadmin work than the app is worth. Start managed; graduate to a VPS when you have a concrete reason.

From source code to a servable artifact

You never deploy your source tree. What ships is the build artifact — the optimized output your build tool produces. Understanding this flow explains almost everything about caching and env vars later.

sourcesrc/, .ts, .jsxbuildartifact (dist/)index.htmlapp.4f9a2c.jsmain.b17e.cssuploadCDN edgecopies cachedworldwideusercache HIT — served from edge, origin untouchednearest edge node
The deploy pipeline. Source is transformed once into a folder of static assets with content-hashed names, that folder is uploaded to the host, and the CDN serves it from the edge. The origin is only touched on a cache miss.

The build step does the heavy lifting: it transpiles TypeScript, bundles modules, minifies, tree-shakes dead code, and — critically — renames every asset with a hash of its contents. A tool like Vite emits files such as app.4f9a2c8b.js. That hash is the key to everything about caching, which we’ll get to.

A minimal deploy is often just two commands:

# produce the artifact
npm run build      # writes an optimized dist/ (or build/, .output/)

# upload it to the host
npx wrangler pages deploy dist

Most people never run that second command by hand. Instead they connect the host to a Git repo, and every push triggers a build-and-deploy in the cloud. More on that below.

Environment variables and secrets

Your app needs configuration that differs between your laptop and production: API keys, database URLs, feature flags. These never belong in your source code. Hardcode a secret and it lives forever in Git history, visible to anyone who clones the repo. The rule is absolute: secrets go in the environment, not the codebase.

But “env vars in deployment” hides a distinction that trips up almost everyone, and it comes straight from the build flow above.

build-time — baked into the bundleVITE_API_URLread at buildapp.4f9a.jsvalue inlinedshipped to browseranyone can read it — NOT secretruntime — injected on the serverDATABASE_URLread per requestserver functionprocess.env.DATABASE_URLstays on the servernever sent to the client — safeRule of thumb: if a var name has a public prefix (VITE_, PUBLIC_, NEXT_PUBLIC_),assume the whole world can read its value. Never put a secret behind that prefix.
Build-time variables are read while the artifact is being built and baked into the JavaScript that ships to the browser — so they are public. Runtime variables are read by the server on each request and never leave the server — so they can hold secrets.

Build-time variables are substituted into your code while it bundles. Vite only exposes variables prefixed with VITE_ to client code, and it inlines the literal value into the bundle. Whatever you put there ships to every browser in plain text. Fine for a public API base URL; catastrophic for a secret key.

Runtime variables are read by server code as it handles a request, through process.env (or the platform’s bindings). They live only on the server and never reach the browser, so this is where genuine secrets belong — database passwords, private API keys, signing secrets.

Locally, you keep these in a .env file that is git-ignored:

# .env  — never committed
DATABASE_URL="postgres://localhost:5432/dev"
STRIPE_SECRET_KEY="sk_test_51H..."
VITE_API_URL="http://localhost:8787"
# .gitignore
.env
.env.*
!.env.example

Commit a .env.example with the keys and dummy values so teammates know what to fill in, but never the real secrets. In production you don’t ship the .env file at all — you paste the values into your platform’s secret store (Vercel/Netlify project settings, wrangler secret put, Fly secrets), where they’re encrypted at rest and injected at deploy or request time.

Caching: fast pages that still update

Here is the tension at the heart of web performance. You want assets cached aggressively so repeat visits are instant and you don’t pay to re-serve the same file a million times. But you also need users to get your new code the moment you deploy, not a stale copy from last week. Those goals seem to fight — and content hashing is what resolves them.

Because the build renamed your asset to app.4f9a2c8b.js, the filename is a fingerprint of its contents. Change one character of source and the next build produces app.9b3e01f7.js — a different URL. So you can tell browsers and CDNs to cache the hashed file forever: it will never change, because if the content changes, the name changes and it’s a brand-new URL. The only file that must stay fresh is the small HTML entry point that points at the current hashes.

/assets/app.4f9a2c8b.jsmax-age=31536000, immutablecache 1 year, never revalidate/index.htmlno-cache (revalidate every time)always check for a fresh deploydeploy new versionHTML changes -> points at app.9b3e01f7.js -> browser fetches the one new file
The two-speed strategy. Content-hashed assets are immutable and cached for a year. The HTML that references them is never cached, so a deploy is picked up instantly — the new HTML points at the new hashes, which the browser fetches once and then caches forever too.

On Cloudflare Pages and Netlify you express this with a plain-text _headers file in your build output. Order matters — specific rules override the catch-all:

# _headers  — placed in your build output (e.g. public/ or dist/)

# default: HTML and everything not matched below — always revalidate
/*
  Cache-Control: public, max-age=0, must-revalidate

# fingerprinted assets — safe to cache forever
/assets/*
  Cache-Control: public, max-age=31536000, immutable

The three directives you actually reach for:

  • max-age=<seconds> — how long a copy is considered fresh. While fresh, the browser serves it from disk with zero network. 31536000 is one year, the conventional “forever.”
  • immutable — promises the file will never change, so the browser skips even the revalidation request on a hard refresh. Only ever safe on content-hashed filenames.
  • no-cache — misleadingly named: it does not mean “don’t cache.” It means “cache, but revalidate with the server before every use.” Perfect for HTML. To truly forbid storage, use no-store.

Play with the directives below. Set a Cache-Control policy, move the clock forward, and watch how the browser and the CDN each decide whether to serve from cache, revalidate, or refetch from the origin.

interactiveCache-Control explorer

Domains, DNS, and TLS

Your app is deployed and reachable at something like my-app-7f3.pages.dev. To put it on example.com, you connect three things: a domain name, DNS records that translate that name into a route, and a TLS certificate so the connection is encrypted.

browser1. resolveDNSname -> host2. IP3. TLS handshake (https)CDN edgecert + cached assetsserves HITs directly4. miss onlyoriginyour appMost requests stop at the edge. The origin only runs when the edge cache has no answer.
What happens when someone types your domain. DNS resolves the name to your host, the browser opens an encrypted TLS connection, the CDN serves cached assets from the edge, and only cache misses reach the origin.

You only need a few DNS record types:

  • A record — maps a name straight to an IPv4 address (AAAA is the IPv6 version). Used when your host gives you a fixed IP.
  • CNAME record — maps a name to another name, like my-app-7f3.pages.dev. This is what most managed hosts ask for, because their edge IPs can change and a CNAME follows them.

The wrinkle is the apex (also called the root or naked domain): example.com with no subdomain. The DNS spec forbids a plain CNAME on the apex, so hosts offer a workaround — CNAME flattening or an ALIAS record — that behaves like a CNAME but is legal at the root. A typical setup:

# at your DNS provider
example.com        ALIAS   my-app-7f3.pages.dev    (apex, via flattening)
www.example.com    CNAME   my-app-7f3.pages.dev

Then you decide whether example.com or www.example.com is canonical and redirect the other to it — pick one and be consistent, so you don’t split SEO and cookies across two hostnames.

TLS — the certificate that turns http into https — is almost entirely automatic now. Managed platforms provision a free certificate from Let’s Encrypt the moment your DNS points at them, and renew it before it expires. You typically do nothing beyond adding the domain. On a VPS you run this yourself with a tool like Certbot or Caddy (which handles it transparently), but the underlying protocol, ACME, is the same.

Continuous deploys, previews, and rollbacks

Running the deploy command by hand gets old and error-prone fast. The standard workflow connects your host to your Git repository so deploys happen automatically. This is CI/CD applied to shipping.

push a branchopen a PRpreview deploypr-42.my-app.pages.devreview, then mergemerge to mainpromote to productionlive on example.comv43 (current)rollbackre-point domain to v42instant — v42 is already built and cached
Git-driven deploys. Every pull request gets its own throwaway preview URL to review; merging to the main branch promotes a build to production; and a rollback just re-points the domain at a previous, already-built deployment.

Three habits fall out of this, and they are what make continuous deployment safe rather than scary:

Preview deployments. Every pull request builds to its own isolated URL. You review the actual running app, not a diff — click through it, share the link, catch the broken thing before it reaches users. When the PR merges, that preview is discarded.

Promotion to production. Merging to your main branch triggers the production build and points your domain at it. This is why a clean main branch matters: main is production.

Instant rollback. Because every past deploy is kept as an immutable, already-built artifact, reverting is not a rebuild — the platform just re-points your domain at the previous version. On Vercel, an instant rollback reassigns the domain to an existing deployment, so recovery from a bad deploy takes seconds, not a frantic hotfix. Knowing rollback is one click away is what lets you ship often.

A worked example: deploying this course

To make it concrete, here is roughly what shipping a statically generated site (built with a framework like Astro) looks like end to end:

# 1. Build locally to confirm the artifact is sane
npm run build          # -> dist/ with hashed assets + a _headers file

# 2. Connect the repo once (or use the dashboard)
npx wrangler pages project create javascript-codex

# 3. From here on, git does the deploying
git switch -c fix/typo
git commit -am "fix: correct a heading"
git push -u origin fix/typo   # -> preview URL appears on the PR

The _headers file rides along in dist/, so the CDN caches the fingerprinted JS and CSS for a year and revalidates the HTML on every request. The domain’s DNS points at the host with a CNAME (and a flattened ALIAS at the apex), TLS is provisioned automatically, and merging to main promotes the build. No servers to run, nothing to patch, and a one-click rollback if a deploy goes sideways.

If the site later grows an API — user accounts, a search endpoint — you don’t move hosts. You add serverless functions alongside the static assets on the same platform, keep their secrets in the runtime store, and the static-plus-serverless split does the rest.

Summary

  • Pick the target by who runs code and when. Static (CDN) for prebuilt files, serverless/edge for per-request logic that scales to zero, containers for long-running or stateful processes, VPS for full OS control. Start with the leftmost option that fits.
  • You deploy a build artifact, not source. The build fingerprints assets with a content hash, which is the foundation of both cache-busting and long-term caching.
  • Secrets live in the environment, never in Git. Distinguish build-time vars (baked into the client bundle — public) from runtime vars (read on the server — safe for secrets). Use a git-ignored .env locally and the platform’s secret store in production.
  • Cache with two speeds: fingerprinted assets get max-age=31536000, immutable; HTML gets no-cache so deploys are picked up instantly. Express it in a _headers file.
  • Domains need DNS + TLS. A/CNAME records (with flattening or ALIAS at the apex) route the name; TLS certificates are auto-provisioned from Let’s Encrypt on managed hosts.
  • Let Git drive deploys. Preview URLs per pull request, promotion on merge to main, and one-click instant rollbacks make shipping frequently a safe default rather than a gamble.