Skip to main content
Back to notes

Deploying a React and Vite site to Nginx with versioned releases

Still Brewing UAPublished: Updated:

A static site does not need Node.js on the server. It needs a folder of files, correct permissions and a way to swap versions without a window where the site is half-updated. This is the process I use, and the reasoning behind each step.

Build locally, ship the output

Vite turns the project into a folder of plain files: HTML, hashed JavaScript and CSS bundles, and static assets. That folder is the whole website. Building on your own machine keeps the server free of a toolchain it would only use for a few seconds at a time.

Build with the production site URL
VITE_SITE_URL=https://example.com npm run build

The site URL is a build-time value. Vite inlines it into the bundle and into every absolute URL the pages carry — canonical, Open Graph, sitemap. Passing it on the command line keeps a production value out of the repository entirely, so nothing has to be edited before a build and nothing secret ends up committed.

Upload only the build output

Only the contents of the build folder go to the server. Source files, the Git history, tests, lockfiles and dependencies stay on your machine. Less on the server means less to keep secure, less to accidentally serve, and a much faster upload.

This is also why no runtime is required. Nginx reads files from disk and sends them. There is no process to keep alive, no port to reserve, and nothing to restart when the content changes.

One directory per release

Each deployment goes into its own directory named after the short commit hash it was built from. That name makes the question "what is live right now?" answerable in one command, and it makes going back a matter of pointing at a directory that already exists.

Layout on the server
/var/www/example/
├── releases/
│   ├── <previous-release-id>/
│   └── <release-id>/
└── current -> releases/<release-id>

Upload into a temporary directory in your home folder first, verify it there, and only then copy it into place. If a transfer is interrupted, the incomplete copy never reaches the directory the web server reads.

Copy the build output up
ssh -i /path/to/key user@203.0.113.10 'mkdir -m 700 ~/upload-<release-id>'
scp -r -i /path/to/key ./dist/. user@203.0.113.10:~/upload-<release-id>/

Verify the upload before it goes live

File counts and sizes can match while content differs. Comparing a checksum of every file on both sides is quick and removes the doubt entirely.

Compare checksums on both sides
# local
(cd dist && find . -type f | sort | xargs sha256sum) > local.txt

# server
ssh -i /path/to/key user@203.0.113.10 \
  'cd ~/upload-<release-id> && find . -type f | sort | xargs sha256sum' > remote.txt

diff local.txt remote.txt && echo "identical"

If the two lists differ only in the separator between hash and filename, that is a formatting difference between platforms, not a content difference. Compare the hashes themselves before assuming something went wrong.

Ownership and permissions

Files are owned by root and are not writable by the web server. Nginx only ever needs to read them. Directories need the execute bit so they can be traversed; regular files do not.

Safe permissions
sudo chown -R root:root /var/www/example/releases/<release-id>
sudo find /var/www/example/releases/<release-id> -type d -exec chmod 755 {} +
sudo find /var/www/example/releases/<release-id> -type f -exec chmod 644 {} +

Nothing should ever be world-writable. A quick audit after each deployment catches a stray mode before it becomes a habit.

Audit
sudo find /var/www/example/releases/<release-id> -perm /o=w

Switch versions atomically

The Nginx root points at a symlink, never at a release directory. Deploying means moving that symlink. Done carelessly — delete then recreate — there is a brief moment where the root does not exist and visitors get errors.

Creating the new link under a temporary name and renaming it over the old one avoids that gap. A rename within the same filesystem is atomic: every request sees either the old release or the new one, never nothing.

Atomic switch
sudo ln -sfn /var/www/example/releases/<release-id> /var/www/example/current.new
sudo mv -T /var/www/example/current.new /var/www/example/current

Because the configuration did not change, Nginx does not need to be reloaded. It resolves the symlink per request and picks up the new target immediately. Reload only when you actually edit a configuration file.

Keep the previous release

The old directory stays exactly where it was. It costs a few megabytes and it is the entire rollback plan. Deleting old releases is a separate, deliberate housekeeping task — never part of a deployment.

Verify what is actually being served

Check the live URLs over HTTPS rather than reading files on disk. Confirm the pages you expect return 200, that redirects land where they should, and that an address which does not exist returns a real 404 rather than the home page.

Smoke test
curl -I https://example.com/en/
curl -I https://example.com/uk/
curl -I https://example.com/sitemap.xml
curl -I https://example.com/does-not-exist   # expect 404

A genuine 404 matters more than it looks. If unknown paths quietly return the home page, search engines index endless duplicates of it and broken links stay invisible to you.

One trap worth knowing: once HTTP redirects to HTTPS, a request to port 80 returns a short redirect body, not your page. Testing with a Host header against localhost on port 80 will look like the deployment failed when it succeeded. Check over HTTPS.

Rollback

Rollback is the deployment command with the previous release id. Same mechanism, same atomicity, no configuration change and no reload.

Roll back to the previous release
sudo ln -sfn /var/www/example/releases/<previous-release-id> /var/www/example/current.new
sudo mv -T /var/www/example/current.new /var/www/example/current

Write the exact command down before you deploy, not after something breaks. A rollback you have to reconstruct under pressure is not a rollback.

Want something like this built?

These notes come from work I have shipped. If you need the same done properly, tell me what you have in mind.