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.
VITE_SITE_URL=https://example.com npm run buildThe 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.
/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.
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.
# 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.
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.
sudo find /var/www/example/releases/<release-id> -perm /o=wSwitch 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.
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/currentBecause 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.
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 404A 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.
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/currentWrite the exact command down before you deploy, not after something breaks. A rollback you have to reconstruct under pressure is not a rollback.
Related notes
- Bilingual SEO for a React and Vite websiteHow to give a React and Vite site two properly indexable language versions with prerendered HTML, per-language metadata, canonical URLs and mutual hreflang.Read the note
- A safe checklist for deploying a static site on a shared serverA practical checklist for adding a static site to a server that already runs other projects, without touching anything that was already working.Read the note
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.