CORS on Vultr is usually not a Vultr problem. It’s an app server, reverse proxy, or object storage config problem that just happens to show up on a Vultr VM, Kubernetes cluster, or load balancer.

I’ve seen teams lose hours blaming firewalls, DNS, even TLS, when the real bug was one missing OPTIONS response or a wildcard used with credentials. So here’s the practical version: what to set, where to set it, and what not to do.

What CORS actually controls

CORS decides whether a browser lets frontend JavaScript read a response from a different origin.

Origin means:

  • scheme: http vs https
  • host: app.example.com vs api.example.com
  • port: 443 vs 8443

So this frontend:

https://app.example.com

calling this API:

https://api.example.com

is cross-origin, even though both are under the same parent domain.

Browsers enforce CORS. curl, server-to-server requests, and backend jobs do not care.

The two headers everybody starts with

The most common response header is:

Access-Control-Allow-Origin: https://app.example.com

Or, for public unauthenticated APIs:

Access-Control-Allow-Origin: *

A real-world example from api.github.com includes:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That second header matters when frontend code needs access to non-simple response headers like rate limit metadata or pagination links.

The rule that bites people

You cannot use:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

That combination is invalid for browsers. If you send cookies or auth credentials, return the exact origin instead of *.

Common Vultr deployment patterns

On Vultr, CORS usually gets configured in one of these places:

  • Nginx reverse proxy on a Cloud Compute instance
  • Apache on a VM
  • App code in Node, Go, Python, PHP, etc.
  • Ingress/controller layer on Vultr Kubernetes Engine
  • Static frontend on one host, API on another

My default advice: set CORS as close to the application as possible unless you have a good reason to centralize it at Nginx or the ingress.

Nginx: simple public API

If your API is public and does not use cookies, this is a clean starting point.

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

    location / {
        add_header Access-Control-Allow-Origin "*" always;
        add_header Access-Control-Expose-Headers "ETag, Link, Location, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining" always;

        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin "*" always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0;
            add_header Content-Type text/plain;
            return 204;
        }

        proxy_pass http://127.0.0.1:3000;
    }
}

That works for token-based APIs where the browser sends Authorization: Bearer ... but not cookies.

Nginx: credentialed requests with an allowlist

If your frontend sends cookies or uses fetch(..., { credentials: "include" }), don’t use *. Reflect only approved origins.

map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
}

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

    location / {
        if ($cors_origin != "") {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Vary "Origin" always;
        }

        if ($request_method = OPTIONS) {
            if ($cors_origin = "") { return 403; }

            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Vary "Origin" always;
            add_header Content-Length 0;
            return 204;
        }

        proxy_pass http://127.0.0.1:3000;
    }
}

Vary: Origin is not optional here. Without it, caches can serve the wrong CORS response to the wrong site.

Apache: copy-paste config

For Apache with mod_headers enabled:

<VirtualHost *:443>
    ServerName api.example.com

    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
    Header always set Vary "Origin"

    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} =OPTIONS
    RewriteRule ^(.*)$ $1 [R=204,L]

    Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
    Header always set Access-Control-Max-Age "86400"
</VirtualHost>

If you need multiple allowed origins, don’t hardcode one value. Use app logic or rewrite rules that validate the Origin request header and echo it back only when approved.

Express.js on a Vultr VM

If your app already owns routing, handling CORS in code is often cleaner than stuffing logic into Nginx.

import express from "express";

const app = express();

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Expose-Headers", "ETag, Link, Location, Retry-After");
    res.setHeader("Vary", "Origin");
  }

  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
    res.setHeader("Access-Control-Max-Age", "86400");
    return res.status(204).end();
  }

  next();
});

app.get("/health", (req, res) => {
  res.json({ ok: true });
});

app.listen(3000, () => {
  console.log("Listening on :3000");
});

Go net/http example

package main

import (
	"log"
	"net/http"
)

var allowed = map[string]bool{
	"https://app.example.com":   true,
	"https://admin.example.com": true,
}

func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")

		if allowed[origin] {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Access-Control-Expose-Headers", "ETag, Link, Location, Retry-After")
			w.Header().Set("Vary", "Origin")
		}

		if r.Method == http.MethodOptions {
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
			w.Header().Set("Access-Control-Max-Age", "86400")
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"ok":true}`))
	})

	log.Fatal(http.ListenAndServe(":8080", cors(mux)))
}

Preflight requests: the bit people forget

Browsers send a preflight OPTIONS request when the real request is “non-simple”, usually because of:

  • Authorization header
  • Content-Type: application/json
  • methods like PUT, PATCH, DELETE

Example preflight request:

OPTIONS /users/123 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type

Good response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Vary: Origin

If your Vultr deployment returns 404, 405, or redirects OPTIONS to somewhere else, the browser blocks the real request before it even starts.

Testing from the shell

Use curl so you can see headers clearly.

Simple request:

curl -i https://api.example.com/health \
  -H 'Origin: https://app.example.com'

Preflight test:

curl -i -X OPTIONS https://api.example.com/users/123 \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PATCH' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

Credentialed flow check:

curl -i https://api.example.com/me \
  -H 'Origin: https://app.example.com' \
  -H 'Cookie: session=abc123'

You’re looking for:

  • the right Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials: true if using cookies
  • Vary: Origin for dynamic origin handling
  • proper OPTIONS response

Exposing custom headers to frontend code

If your frontend needs to read headers like pagination, rate limits, or object versioning, expose them explicitly.

Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining

Without that, browser JS can’t read those headers even though they exist on the response.

The GitHub API is a solid real-world reference here. It exposes a long list of operational headers because clients genuinely need them.

Mistakes I see all the time

1. Wildcard plus credentials

Broken:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

2. Allowing every origin by reflecting blindly

Broken server logic:

res.setHeader("Access-Control-Allow-Origin", req.headers.origin);

That’s not an allowlist. That’s “yes to everyone.”

3. Forgetting Vary: Origin

Dynamic origin reflection without Vary: Origin can poison caches.

4. Missing OPTIONS on the reverse proxy

Your app may support CORS perfectly, but Nginx or Apache blocks OPTIONS before the request reaches it.

5. Trying to fix CORS in the frontend

You can’t patch around bad server CORS with client-side JavaScript. The browser is the one enforcing it.

A sane default for most Vultr apps

If I were deploying a typical frontend + API stack on Vultr today:

  • frontend at https://app.example.com
  • API at https://api.example.com
  • Nginx reverse proxy in front of the app
  • cookie or session auth

I’d use:

  • exact allowlist of origins
  • credentials enabled
  • Vary: Origin
  • explicit OPTIONS handling
  • explicit exposed headers only when needed

And I’d keep the CORS policy narrow. Don’t turn your authenticated API into a public cross-origin free-for-all just because local development was annoying.

If you’re also tightening other browser-enforced security headers, see the CSP material at https://csp-guide.com. For CORS itself, the official HTTP and browser docs are still the source of truth.