How to Deploy a Preact App With Docker [Full Repo]

Build the native Preact Vite app into a two-stage Docker image, put it on a public HTTPS URL, and verify the result — with the transfer measured at every hop, from your own nginx to the live deployment.

A Preact app's path from vite build through a two-stage Docker image to a live HTTPS URL, with the gzipped first-load JavaScript transfer shown as the headline measurement

You chose Preact; this page gets it deployed. The flow is the standard one for a static Vite app — a two-stage Docker image, nginx serving the build — and because transfer size is usually why Preact is in the room, this guide measures the payload at every hop instead of asserting it.

What we deploy is the native Preact app from the official Vite template, live on Back4app Containers; dates, commit, and all figures are gathered in the measurements section, and the setup is reproducible from the companion repo.

One scoping note up front: preact/compat, the React-aliasing layer, is real and officially documented, but the companion repo does not demonstrate it, so this guide will not pretend to.

Scaffold the Preact app

The companion repo began life as the official template, and nothing framework-specific was added before deploying it.

npm create vite@latest preact-container-starter -- --template preact

That pinned preact ^11.1.1, vite ^8.3.6, and @preact/preset-vite ^2.10.6 in our run.

Before Docker can build it, commit a lockfile: our fresh scaffold had none, so we ran npm install once and checked in the package-lock.json it wrote — npm ci inside the image depends on it.

Build the Docker image

The Dockerfile is the standard split — a Node stage that exists only to produce dist/, and an nginx stage that is the actual shipped container.

# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Serve stage
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080

A distinction worth keeping straight: the image is almost entirely the nginx base and never crosses the network to your users; what they download is the build output, governed by the nginx config below. Both sizes are in the measurements section, as separate facts.

server {
    listen 8080;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    gzip on;
    gzip_proxied any;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_min_length 1024;
    gzip_types text/css application/javascript application/json image/svg+xml text/plain;

    # Vite writes content-hashed files to /assets/ — cache them forever
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    # SPA refresh fix: unknown paths fall back to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Three lines carry the transfer behavior: compression (gzip_comp_level 6 against nginx’s default of 1 — measured difference in the table), immutable caching scoped to the content-hashed /assets/ files (verified served with Cache-Control: public, max-age=31536000, immutable), and the try_files fallback that keeps deep links alive.

Deploy on Back4app

A plan fact to weigh before you choose one: a free-plan app gets a public URL that shuts down 60 minutes after the app is created, redeploys included — enough runway for every check in this guide, not for serving users. Permanent URLs come with the paid plans.

  1. Push everything (app, Dockerfile, nginx.conf, lockfile) to GitHub and open a New Web Deployment pointed at the repo.

Choosing a source for a new Web Deployment, with the GitHub repository path highlighted

Source selection is the same screen series-wide — captured during our React deploy, dashboard as of November 2026.

  1. Dockerfile detection is automatic; the port is your job. Set 8080 explicitly — the auto setting does not inherit EXPOSE, a known trip-up whose exact failure message the React article reproduces.

Build & deploy settings with Dockerfile as build method and 8080 entered in the Port field

Settings step reused from the React deploy where it is identical. Dashboard as of November 2026.

  1. Create the app and wait out a short build — the READY timestamp from our run is in the measurements section.

The first-deploy dialog showing the temporary HTTPS URL with its 60-minute countdown

The free-URL dialog, from the React deploy — the step does not differ. Dashboard as of November 2026.

  1. Now verify the live URL: fetch the referenced script with Accept-Encoding: gzip and count the bytes it transfers, and request a deep path — ours returned 200, the fallback at work in production.

Deploy from an AI assistant with MCP

With Back4app’s remote MCP server connected, an AI assistant can upload this folder and deploy it for you. MCP only operates the deploy; the build is still the Dockerfile above. Setup per client: official MCP page.

The general MCP flow, including why the buildpack path failed for this app, is in the MCP deploy guide.

On November 10, 2026 (Claude, custom connector, signed in) the assistant ran create_upload, the returned npx @back4app/cli upload command, and create_web_app with port 8080. The app was created with builder: DOCKERFILE and was READY about 18 seconds later; / and an unknown deep path both returned 200.

Keep the Dockerfile on this path. We repeated the deploy asking for the buildpack instead (builder: BUILDPACK), and it failed: the build passed, then the deployment stopped with “Your app was built successfully, but it has no start process”.

Through MCP, the buildpack did not treat this Vite app as a static site. A static build needs something to serve it — which is what the nginx stage of the Dockerfile is. Both runs, with the full error text: MCP evidence file.

What we measured

Local figures are from November 6, 2026; the live deploy on November 9, 2026 ran the repo at commit 521c9af (deployed lab commit and raw commands in the repo’s evidence files).

On-disk sizes and observed transfers get separate tables.

Local build and on-disk sizes (November 6, 2026):

WhatValue
npm ci (89 packages, warm npm cache)1.38 s
npm run build2.41 s wall (Vite: built in 473 ms)
dist/ on disk80 KB
docker build, base images already present10.00 s
Docker image76 MB (arm64; the app is a rounding error of it)
First-load JS, raw file size17.97 KB (one file)

Observed transfers and timing:

WhatValueWhere
First-load JS from our nginx, gzip level 67,335 Blocal container, November 6, 2026
Same file at nginx’s default gzip level 17,941 Blocal container
First-load JS on the public URL9,816 B before gzip_proxied any; · 7,335 B afterlive URL, November 9 and 10, 2026
HTML document, gzipped473 Blocal container
Platform build READY13:38:35 UTClive deploy, November 9, 2026
TTFB on / (3 illustrative requests)1.650 s · 1.002 s · 1.393 slive URL

The two public figures are the same file before and after one config line. Without gzip_proxied any;, nginx did not compress requests relayed by the platform’s CDN, and the edge’s own compression delivered 9,816 B. With the line, the public URL transfers the 7,335 B this config produces (verified November 10, 2026).

One piece of secondary data, since the question always comes up: at the commits each evidence file records, this Preact scaffold’s origin transfer (7,335 B) sits well under the figure the React deploy guide reports for its starter (68.6 KB) on the same server setup.

The scaffolds differ in demo content and dependencies, so read that as a comparison of these two repos — not as an intrinsic framework ratio, and not as a claim about load time.

Update the deployed app

After the first deploy, updates are a push plus one dashboard action — App actions → Deploy the latest commit — and the same URL serves the new build. We ran that flow on this platform during the November 9, 2026 session; the fully instrumented version of the test, with its caveats, is in the React article.

Paid plans skip the click via auto-deploy. Avoid re-creating the app to update it: that path changes the URL.

Troubleshooting

The npm ci command can only install with an existing package-lock.json — no lockfile is committed. npm install once, commit the file, rebuild.

Please mention a port in settings to expose — the Port field stayed on auto, which never consults the Dockerfile. Set 8080 in Build & deploy.

A route URL 404s on reload — the try_files fallback is missing or an edited nginx.conf is not the one the image copies. The config above returned 200 on our live deep path.

The transfer is bigger than you expected — check you are measuring with Accept-Encoding: gzip, that your file types are listed in gzip_types, and — behind a CDN or any proxy — that gzip_proxied any; is set, because nginx otherwise answers proxied requests uncompressed.

Limitations of this flow

The setup is small on purpose, and these are its actual boundaries.

  • Static files only. Any server-side rendering or per-request logic is a different artifact needing a Node runtime container, not this nginx image.
  • compat is out of scope here. If your app needs preact/compat, validate that build against the official guide — this repo’s evidence covers native Preact only.
  • Bytes are not milliseconds. We measured transfer sizes and TTFB separately; a smaller payload is not by itself a load-time claim for your users.
  • Free-plan URLs expire. An hour per app is a test bench, not a host; serving users needs a paid plan.
  • No backend included. Accounts and shared data are a next step when you need them — options include a managed backend or an API service of your own.

What’s next?

Run your own app through the same flow: build it with the companion repo’s two files, deploy free, and measure the live transfer yourself (Containers docs for the dashboard details).

When features arrive that want accounts or shared state, one way to keep the bundle lean is calling a managed backend over REST or GraphQL instead of growing the client.

Tagspreactdockervitenginxcontainersdeployspaperformance

Frequently asked questions

Is Preact compatible with React libraries?

Preact publishes preact/compat, an aliasing layer that lets much of the React ecosystem run on Preact — the official switching guide at preactjs.com documents how. This tutorial and its companion repo deploy native Preact without compat, so we make no tested claims about it; what we verified is the native app, built and live. If your project depends on React libraries, evaluate compat against its official docs before planning the deploy.

How much smaller is Preact than React in production?

We can only answer for the scaffolds we measured: at the commits recorded in each repo's evidence files, this Preact starter's first-load JavaScript transferred as 7,335 bytes from our nginx, while the React starter in the companion React deploy guide reports 68.6 KB from the same server setup. The two scaffolds differ in demo content and dependencies, so treat this as scaffold data, not an intrinsic framework ratio — your own gap depends on what you build.

When should I choose Preact over React?

Criteria worth weighing, with no universal answer: how much your project gains from a smaller baseline transfer, how much it depends on React-ecosystem libraries (and whether preact/compat covers them), your team's familiarity, and the API surface you need. Whichever way those point for you, the deploy flow on this page is the same two files.

Why is my Preact app 404 after deployment?

Deep links 404 when the web server looks for a file matching the route and finds only index.html. Give nginx the fallback rule try_files $uri $uri/ /index.html so unknown paths return the app. That rule is in this guide's config, and our live deploy answered 200 on a deep path with it.

Does Preact work with Vite and Docker out of the box?

Nearly. npm create vite with the preact template scaffolds an app that builds fast (the figures are in the measurements section), and the standard two-stage Dockerfile applies unchanged. The one speed bump we hit: the fresh scaffold had no package-lock.json, and npm ci in the build stage demands one — run npm install once and commit the lockfile.

Can I deploy a Preact app for free?

Yes — our live deploy ran on Back4app Containers' free plan (0.25 vCPU, 256 MB), resources a Preact app barely registers against. The constraint to plan around is time, not size: a free app's public URL lasts 60 minutes from its creation. Verification fits; hosting means a paid plan, where the URL is permanent.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Nov 9, 2026 — Local: Node 22 LTS, Docker CLI (Colima) on an Apple Silicon Mac · Live: Back4app Containers, free plan (0.25 vCPU / 256 MB), deployed from GitHub, measured November 9, 2026.

Changelog

  • Nov 10, 2026 — Added a section on running this deploy from an AI assistant through the Back4app MCP server, tested on this starter.
  • Nov 10, 2026 — The gap between origin and public transfer sizes is now explained and fixed: nginx needed gzip_proxied any to compress requests relayed by the CDN. Config and figures updated.
  • Nov 10, 2026 — Free-plan URL lifetime restated: 60 minutes from the app's creation, not per deploy. Added a link to the full MCP deploy walkthrough.

More from Back4app Engineering

Web Deployment · alternative

Top 10 Vue Alternatives in 2026 (Compared & Ranked)

Containers · tutorial

How to Deploy a React App: 3 Methods, Step by Step [Full Repo]

Web Deployment · tutorial

App Works in Preview but Not in Production: 404 on Refresh, No Data, Login Sent to localhost