Serve Astro with Nginx: Production Settings

How to serve Astro with Nginx on a VPS: dist folder deploys, HTTPS redirects, try_files, gzip, cache headers, security headers, curl tests, and 404 pages.

Quick answer

How do you serve an Astro static site with Nginx?

Build the Astro site into a dist folder, upload the generated files to your web root, then configure Nginx to serve static files with HTTPS redirects, a correct root path, try_files fallback, gzip compression, cache headers, security headers, and a real 404 page.

Nginx serving an Astro static site with production settings for gzip, redirects, cache headers, and security headers

Learning how to serve Astro with Nginx made deployment feel almost too simple: run a build command, get a dist/ folder, put that folder on a server, done. That simplicity is one of the reasons I like Astro.

But after optimizing doancongtuan.com for Lighthouse, I learned one important lesson:

A static site is only as good as the way you serve it.

Astro can generate clean HTML, CSS, JavaScript, and assets. But Nginx still has to serve those files correctly. If compression is missing, redirects are messy, cache headers are weak, or the 404 behavior is wrong, the site can still feel unfinished in production. This article is the Nginx side of my Astro performance cleanup. Not a giant enterprise config, but the small set of server settings that actually mattered for my own site.

How do I serve Astro with Nginx in production?

doancongtuan.com runs as a static Astro site on a VPS. No Node.js app in production, no long-running process to babysit. The workflow is simple:

Astro project
→ npm run build
→ dist/ folder
→ rsync to VPS web root
→ Nginx serves static files

My deploy command looks like this:

npm run build
rsync -av --delete dist/ /home/doancongtuan.com/public_html/

That is it. No PM2, no Node process manager, no SSR server, no PHP, no database. Just static files served directly by Nginx.

First-hand experience: Based on direct hands-on use. doancongtuan.com runs on Vultr VPS with Rocky Linux + Nginx. The settings in this article come from the actual production config I verified during the Lighthouse cleanup.

But that does not mean the server config is irrelevant. Nginx still controls:

HTTPS redirects
www vs non-www redirects
file resolution
404 behavior
compression
cache headers
security headers
static asset delivery

Those details are easy to ignore. Until Lighthouse, Search Console, or a browser waterfall makes them impossible to.

The minimal Nginx shape for Astro

A normal static Astro site needs a very simple Nginx server block. At the core, it looks like this:

server {
    listen 443 ssl http2;
    server_name example.com;

    root /home/example.com/public_html;
    index index.html;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }
}

The important line is the try_files directive:

try_files $uri $uri/ $uri.html =404;

This tells Nginx to try:

1. The exact file
2. The directory index
3. The .html version
4. A real 404 if nothing matches

For Astro static output, that is usually enough. If your build creates:

/about/index.html
/blog/my-post/index.html

then /about/ and /blog/my-post/ can resolve cleanly. And if your build creates flat files like:

/about.html

then $uri.html handles that too.

Redirect HTTP to HTTPS

The first production rule is simple: HTTP should always redirect to HTTPS.

server {
    listen 80;
    server_name example.com www.example.com;

    return 301 https://example.com$request_uri;
}

I prefer forcing the canonical version at the same time. For this site, I use non-www, so HTTP and www both end up on the clean https://doancongtuan.com/ version.

Redirect www to non-www

If you use the non-www version, redirect www to non-www:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    return 301 https://example.com$request_uri;
}

This matters beyond cosmetics. It prevents duplicate versions of the same page from floating around. You want one canonical host, not four:

http://example.com/page/
http://www.example.com/page/
https://example.com/page/
https://www.example.com/page/

Pick one and redirect the rest. For SEO and analytics, that ambiguity adds up quickly.

The main Astro server block

Here is a practical version of the main server block:

server {
    listen 443 ssl http2;
    server_name example.com;

    root /home/example.com/public_html;
    index index.html;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    error_page 404 /404.html;
}

This assumes your deployed files live at /home/example.com/public_html/. Change that path to match your own server. The important thing is that Nginx’s root should point to the deployed Astro output, not the source project directory.

Wrong idea:

/home/example.com/app/my-astro-project/

Right idea:

/home/example.com/public_html/

That is where the dist/ folder should be synced after each build.

Add a real 404 page

Astro can generate a 404.html page. Make sure Nginx actually uses it:

error_page 404 /404.html;

Then your location / should end in =404:

location / {
    try_files $uri $uri/ $uri.html =404;
}

That gives you a real 404 response instead of silently sending users back to the homepage. A missing page should return 404, not a 200 that looks like the homepage. That confusion matters for SEO and for crawlers trying to understand your site structure.

To test:

curl -sI https://example.com/this-page-should-not-exist/ | head

You want to see:

HTTP/2 404

not:

HTTP/2 200

Enable gzip compression

This was one of the first things I checked during the Lighthouse cleanup. A static site can still be served without compression if Nginx is not configured for it. I like putting gzip in a global include file instead of repeating it in every vhost config.

Example:

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_http_version 1.1;

gzip_types
    text/plain
    text/css
    text/xml
    application/xml
    application/json
    application/javascript
    text/javascript
    image/svg+xml
    application/rss+xml
    application/atom+xml
    application/manifest+json;

Test it with:

curl -sD - -o /dev/null -H 'Accept-Encoding: gzip' https://example.com/ \
  | grep -Ei 'content-type|content-encoding|vary'

A good response includes:

vary: Accept-Encoding
content-encoding: gzip

If you do not see content-encoding: gzip, do not assume compression is working. Check the live response. Config files lie by omission; curl tells the truth.

Add cache headers carefully

Cache headers are useful, but they should not be copied blindly. Astro outputs hashed assets inside /_astro/. Those filenames change when the content changes, which makes them safe candidates for long-term immutable caching.

Example:

location /_astro/ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

That is safe for hashed build assets. For normal public images, be more careful. If your filenames are stable, like:

/images/posts/my-post/hero.webp

and you later replace the image without changing the filename, aggressive immutable caching will leave visitors staring at the old version for months. A safer rule for public images:

location ~* \.(webp|jpg|jpeg|png|svg|ico)$ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000" always;
}

For HTML, I prefer not to cache too aggressively at the browser level:

location ~* \.html$ {
    add_header Cache-Control "no-cache" always;
}

The simple rule: hashed assets get long cache plus immutable, HTML gets no-cache, public images get moderate cache unless their filenames are versioned. That balance keeps performance solid without making deploys a headache.

Add basic security headers

For a static Astro site, basic security headers are simple to add and worth having. A minimal set:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

These are not magic and they do not make a site invincible, but they clean up common browser-level protections and improve best-practice audit scores. I usually avoid adding a strict Content Security Policy too early. CSP is powerful, but if you copy a random config without understanding it, it will break analytics, images, inline scripts, and embeds. Start with the safe headers, then add CSP later when you have time to test it properly.

Hide Nginx version

This is a small hardening step. In /etc/nginx/nginx.conf, inside the http block:

server_tokens off;

Then reload:

nginx -t
systemctl reload nginx

This hides the exact Nginx version from default error pages and response headers. Not a major security feature on its own, but a reasonable cleanup that takes one line.

A practical full example

Here is a simplified example for a non-www Astro site. Use it as a starting point, not something to paste blindly.

# HTTP → HTTPS non-www
server {
    listen 80;
    server_name example.com www.example.com;

    return 301 https://example.com$request_uri;
}

# HTTPS www → non-www
server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    return 301 https://example.com$request_uri;
}

# Main site
server {
    listen 443 ssl http2;
    server_name example.com;

    root /home/example.com/public_html;
    index index.html;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    location /_astro/ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    location ~* \.(webp|jpg|jpeg|png|svg|ico)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000" always;
    }

    location ~* \.html$ {
        add_header Cache-Control "no-cache" always;
    }

    error_page 404 /404.html;
}

If you already have global gzip and security header include files, do not duplicate everything inside each vhost. Keep your config clean.

Test before reload

Always test the Nginx config before reloading:

nginx -t

If the test passes, reload. Don’t restart unless you have a reason to:

systemctl reload nginx

A reload applies config changes without dropping active connections. Restart is heavier than it needs to be for a config update.

Test the live site

After deploying and reloading, check the live site. Don’t skip this step.

Check redirect

curl -sI http://example.com/ | grep -Ei 'HTTP/|location'

Expected:

HTTP/1.1 301 Moved Permanently
location: https://example.com/

Check www redirect

curl -sI https://www.example.com/ | grep -Ei 'HTTP/|location'

Expected:

HTTP/2 301
location: https://example.com/

Check gzip

curl -sD - -o /dev/null -H 'Accept-Encoding: gzip' https://example.com/ \
  | grep -Ei 'content-type|content-encoding|vary'

Expected:

content-type: text/html
vary: Accept-Encoding
content-encoding: gzip

Check 404

curl -sI https://example.com/this-page-should-not-exist/ | head

Expected:

HTTP/2 404

Check security headers

curl -sI https://example.com/ \
  | grep -Ei 'x-content-type-options|x-frame-options|referrer-policy|permissions-policy'

Expected:

x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()

These checks are boring, but they are the difference between “I think the site is configured correctly” and “I know the live server is sending the right response.”

Where Nginx fits into Lighthouse

Nginx alone will not give you a perfect Lighthouse score. It does not fix bad images, remove render-blocking fonts, delay analytics, or improve text contrast. But it controls several important foundations:

Compression
redirect behavior
static file delivery
cache headers
404 status codes
security headers

During my Lighthouse cleanup, the Nginx part was one layer, not the whole story. The full case study is here: How I Took My Astro Site from Good to 100 Lighthouse Scores. The image-specific deep dive is here: Astro Image Optimization with Sharp.

The Nginx layer matters because static files are only the output. The server is how users actually receive them.

Should you use Nginx or a static hosting platform?

Not everyone needs a VPS. For many Astro sites, Cloudflare Pages, Vercel, or Netlify is simpler. I cover that in detail in Best Hosting for Astro Sites. The short version:

Use Cloudflare Pages if you want the easiest global static hosting.
Use Vercel or Netlify if you want a smooth Git-based workflow.
Use VPS + Nginx if you want control, already manage servers, or host multiple sites.

For my own workflow, VPS + Nginx makes sense. I already run a VPS, I like having control over the server, I can host multiple small static sites on one machine, and deploying is just a rsync. If you are new to VPS setup, the VPS series covers the full stack from your first SSH connection to running WordPress, and the Nginx foundation there applies equally to Astro static sites. That said, it is not the best answer for everyone. It is just the one that fits me.

Steven Uses ThisVultr
Get Vultr →

Common mistakes

Here are the mistakes I would avoid.

Pointing Nginx to the source project

Serve the generated output, not the Astro source directory. Build first, then sync dist/ to the public web root. Wrong idea: /home/example.com/app/my-astro-project/. Right idea: /home/example.com/public_html/.

Caching HTML too aggressively

Long cache is great for hashed assets, not always great for HTML. If you cache HTML too aggressively in the browser, users may keep seeing old pages after you deploy updates. Be careful with immutable. Use it only where filenames change with content.

Returning 200 for missing pages

Do not send every missing URL to the homepage. That creates soft 404 problems that confuse crawlers and give you no useful signal about broken links. A missing page should return a real 404.

Forgetting to test live headers

Your config file is not the final truth. The live response is. Use curl.

Copying a strict CSP too early

A Content Security Policy can be very useful, but a bad one will break analytics, images, inline scripts, external scripts, and embeds. Start with safer headers first. Add CSP later when you are ready to actually test it.

The honest bottom line

Astro makes production simpler because the output is just static files, but “just static files” does not mean the server no longer matters. Nginx still controls how those files are delivered, and for my own site the important pieces were not complicated:

HTTPS redirects
non-www canonical host
correct root path
try_files
real 404
gzip
cache headers
security headers
live curl tests

That is the boring checklist, but boring checklists are what make fast static sites actually feel production-ready.

Frequently Asked Questions

Does Astro need Node.js in production when served with Nginx?
For a normal static Astro site, no. You run npm run build, upload the dist folder, and Nginx serves the generated HTML, CSS, JavaScript, images, and assets as static files. Node.js is only needed during build time, unless you use SSR.
What is the most important Nginx setting for an Astro static site?
The root and try_files rules are the most important because they control how static pages are resolved. After that, HTTPS redirects, gzip compression, cache headers, security headers, and a real 404 page are the most practical production settings. Start with file resolution first.
Should I cache Astro files for one year?
Hashed Astro assets inside /_astro/ are good candidates for long cache headers with immutable because their filenames change when the content changes. For public images or HTML pages, use more conservative cache rules unless your filenames are versioned. Cache rules should match your deploy workflow.
Is Nginx faster than using Cloudflare Pages or Vercel?
Not automatically. Cloudflare Pages and Vercel use global CDN infrastructure, which is excellent for static sites. Nginx on a VPS is best when you want control, already run a VPS, or host multiple sites on the same server. Simpler hosting may be better for beginners.
Can bad Nginx settings hurt Lighthouse?
Yes. Missing compression, weak cache headers, redirect chains, or incorrect 404 handling can affect performance, best practices, and crawl quality. Astro generates good static files, but the server still has to serve them properly. Delivery is part of real performance.
Nginx is running but my Astro site returns 404 on all pages. What should I check?
Check the root path in your Nginx config. It should point to the generated dist folder, not the source project directory. Also verify that try_files is set correctly: $uri $uri/ $uri.html =404. If you recently deployed, confirm that rsync or your copy command actually moved the files to the web root.

Disclosure: Some links on this page are affiliate links. If you make a purchase through them, I may earn a small commission at no extra cost to you. I only recommend products I've genuinely evaluated. Full disclosure →