An OpenAPI document describes a REST API in YAML or JSON: its endpoints, parameters, request bodies, responses and authentication. Swagger UI turns that file into interactive documentation where developers can send test requests, and ReDoc renders the same file as a clean three-panel reference. In this tutorial you will write and validate an OpenAPI 3 spec, self-host both viewers as static files, and serve them with Nginx over HTTPS on Ubuntu 24.04, with a version selector for several API versions.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with
sudoprivileges. - A domain name with an A record pointing to the server. This guide uses
docs.your_domain; replace it with your own hostname everywhere. - Ports 80 and 443 open to the Internet.
No Node.js or build step is needed. Both viewers are plain static files.
Step 1 - Installing Nginx and preparing the directories
Install Nginx, the Certbot plugin for Nginx, and pipx, which you will use to install the validator:
sudo apt update
sudo apt install -y nginx python3-certbot-nginx pipx
sudo ufw allow 'Nginx Full'
Create one directory per component under /var/www/api-docs and give your user ownership, so you can update the docs without sudo:
sudo mkdir -p /var/www/api-docs/{swagger,redoc,specs/v1}
sudo chown -R "$USER":"$USER" /var/www/api-docs
The final layout will be:
| Path | Content |
|---|---|
/var/www/api-docs/specs/v1/openapi.yaml | The OpenAPI document for version 1 of the API |
/var/www/api-docs/swagger/ | Swagger UI static files |
/var/www/api-docs/redoc/ | ReDoc page and script |
Step 2 - Writing the OpenAPI specification
The spec is the single source of truth: both viewers only display what it contains. Create a small but complete example with two endpoints, reusable schemas, error responses and API key authentication:
nano /var/www/api-docs/specs/v1/openapi.yaml
openapi: 3.0.3
info:
title: Example API
version: 1.0.0
description: |
Manage the users of your account.
Authenticate every request with your API key in the `X-API-Key` header.
servers:
- url: https://api.your_domain/v1
description: Production
security:
- ApiKeyAuth: []
tags:
- name: Users
description: User management
paths:
/users:
get:
tags: [Users]
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
description: Maximum number of users to return.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
"200":
description: A page of users.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/User"
total:
type: integer
example: 42
"401":
$ref: "#/components/responses/Unauthorized"
post:
tags: [Users]
summary: Create a user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUser"
responses:
"201":
description: The created user.
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"401":
$ref: "#/components/responses/Unauthorized"
/users/{userId}:
get:
tags: [Users]
summary: Get a user
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
example: usr_123
responses:
"200":
description: The requested user.
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"404":
$ref: "#/components/responses/NotFound"
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
schemas:
User:
type: object
required: [id, name, email]
properties:
id:
type: string
example: usr_123
name:
type: string
example: Alice Smith
email:
type: string
format: email
example: [email protected]
created_at:
type: string
format: date-time
CreateUser:
type: object
required: [name, email]
properties:
name:
type: string
minLength: 2
maxLength: 100
email:
type: string
format: email
Error:
type: object
properties:
detail:
type: string
responses:
Unauthorized:
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: The resource does not exist.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
A few conventions make the rendered docs much more useful: a unique operationId per operation (code generators use it for method names), example values on schemas (Swagger UI uses them to prefill requests), and shared error responses under components so every endpoint documents them the same way.
Step 3 - Validating the specification
A spec with a broken $ref or a wrong field type renders as an error page or, worse, as silently incomplete docs. Install openapi-spec-validator in its own isolated environment with pipx:
pipx install openapi-spec-validator
pipx ensurepath
pipx ensurepath adds ~/.local/bin to your PATH. Log out and back in, or open a new shell, then validate the file:
openapi-spec-validator /var/www/api-docs/specs/v1/openapi.yaml
/var/www/api-docs/specs/v1/openapi.yaml: OK
To see what an error looks like, change $ref: "#/components/schemas/User" to $ref: "#/components/schemas/Usr" in one place and run the validator again. It reports the unresolvable reference and exits with a non-zero code, which is what you want in a CI pipeline. Undo the change afterwards.
Step 4 - Installing Swagger UI
Swagger UI publishes a ready-to-serve dist folder in every release. Download the latest release and copy that folder into place:
SWAGGER_UI_VERSION=$(curl -fsSL https://api.github.com/repos/swagger-api/swagger-ui/releases/latest | grep -Po '"tag_name": "v\K[^"]+')
echo "$SWAGGER_UI_VERSION"
curl -fsSL -o /tmp/swagger-ui.tar.gz "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v${SWAGGER_UI_VERSION}.tar.gz"
tar -xzf /tmp/swagger-ui.tar.gz -C /tmp
cp -r "/tmp/swagger-ui-${SWAGGER_UI_VERSION}/dist/." /var/www/api-docs/swagger/
The echo prints the version it found, for example 5.x.y. The dist folder contains index.html, the JavaScript bundles, and swagger-initializer.js, which configures the page. By default it points to the public Petstore demo. Replace its contents:
nano /var/www/api-docs/swagger/swagger-initializer.js
window.onload = function () {
window.ui = SwaggerUIBundle({
urls: [
{ url: "/specs/v1/openapi.yaml", name: "v1" }
],
"urls.primaryName": "v1",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
persistAuthorization: true
});
};
The urls list fills the "Select a definition" drop-down in the top bar, which you will use for API versions in Step 7. deepLinking gives every operation a shareable URL, and persistAuthorization keeps the API key a developer enters in the browser across page reloads.
Clean up the downloaded archive:
rm -rf /tmp/swagger-ui.tar.gz "/tmp/swagger-ui-${SWAGGER_UI_VERSION}"
Step 5 - Installing ReDoc
ReDoc ships as a single JavaScript file. Download version 2 from the jsDelivr CDN and serve it yourself, so the docs keep working if the CDN is unreachable and no third party sees your visitors:
curl -fsSL -o /var/www/api-docs/redoc/redoc.standalone.js \
https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js
Create the page that loads it:
nano /var/www/api-docs/redoc/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Example API reference</title>
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<redoc spec-url="/specs/v1/openapi.yaml" expand-responses="200,201" required-props-first="true"></redoc>
<script src="/redoc/redoc.standalone.js"></script>
</body>
</html>
expand-responses opens the success responses by default and required-props-first lists required fields before optional ones, which makes long schemas easier to scan.
Step 6 - Serving the docs with Nginx and HTTPS
Create a server block that serves the whole directory:
sudo nano /etc/nginx/sites-available/api-docs
server {
listen 80;
listen [::]:80;
server_name docs.your_domain;
root /var/www/api-docs;
index index.html;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location = / {
return 302 /swagger/;
}
location /swagger/ {
try_files $uri $uri/ =404;
}
location /redoc/ {
try_files $uri $uri/ =404;
}
location /specs/ {
types {
application/yaml yaml yml;
application/json json;
}
# Let other tools and sites load the spec, and always revalidate it
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options nosniff always;
}
}
Nginx's default MIME table has no entry for YAML, so the types block serves specs as application/yaml instead of a download. Cache-Control: no-cache makes browsers check for a new version on every load (a cheap request that usually returns 304 Not Modified), so updated specs appear immediately. The add_header lines are repeated in /specs/ because Nginx drops the server-level headers in any location that defines its own.
Enable the site, check the configuration and reload:
sudo ln -s /etc/nginx/sites-available/api-docs /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Request a Let's Encrypt certificate. Certbot adds the HTTPS server block and a redirect from HTTP to HTTPS, and renews the certificate automatically:
sudo certbot --nginx -d docs.your_domain
Check that the spec is served with the right headers:
curl -sI https://docs.your_domain/specs/v1/openapi.yaml
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/yaml
Access-Control-Allow-Origin: *
Cache-Control: no-cache
X-Content-Type-Options: nosniff
Open https://docs.your_domain/ in a browser. It redirects to Swagger UI with the "Example API" definition and its Users operations. https://docs.your_domain/redoc/ shows the same API in ReDoc.
Step 7 - Publishing several API versions
When you release a new major version, keep the old spec online until clients have migrated. Copy the current spec as the starting point for version 2:
mkdir -p /var/www/api-docs/specs/v2
cp /var/www/api-docs/specs/v1/openapi.yaml /var/www/api-docs/specs/v2/openapi.yaml
In specs/v2/openapi.yaml, set info.version to 2.0.0 and the server URL to https://api.your_domain/v2. In specs/v1/openapi.yaml, mark the operations that are going away with deprecated: true, which both viewers display with a strike-through:
/users/{userId}:
get:
deprecated: true
summary: Get a user
description: Deprecated. Use `GET /v2/users/{userId}` instead.
Add the new version to Swagger UI's drop-down and make it the default:
nano /var/www/api-docs/swagger/swagger-initializer.js
urls: [
{ url: "/specs/v2/openapi.yaml", name: "v2" },
{ url: "/specs/v1/openapi.yaml", name: "v1 (deprecated)" }
],
"urls.primaryName": "v2",
Validate both files, then reload the page in the browser. No Nginx reload is needed because only static files changed:
openapi-spec-validator /var/www/api-docs/specs/v1/openapi.yaml /var/www/api-docs/specs/v2/openapi.yaml
ReDoc shows one spec per page, so create redoc/v1.html as a copy of index.html with spec-url pointing to the v1 file if you need both there too.
Step 8 - Restricting access to internal docs (optional)
Documentation for internal or partner APIs should not be public. Protect the site with HTTP basic authentication. Install the htpasswd tool and create a user, replacing docs_user with the name you want:
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-docs docs_user
Open the site file, which Certbot has extended with an HTTPS server block:
sudo nano /etc/nginx/sites-available/api-docs
Add these two lines inside the server block that contains listen 443 ssl:
auth_basic "API documentation";
auth_basic_user_file /etc/nginx/.htpasswd-docs;
Test and reload Nginx, then confirm that anonymous requests are refused:
sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -w "%{http_code}\n" https://docs.your_domain/swagger/
401
Troubleshooting
Swagger UI shows "Failed to load API definition": the browser could not fetch or parse the spec. Open the spec URL directly, check that it returns 200, and run openapi-spec-validator on it. A 404 usually means the path in swagger-initializer.js does not match the file on disk.
"Try it out" fails with "Failed to fetch" or a CORS error in the browser console: the requests go from the docs domain to your API domain, so the API must answer with CORS headers that allow https://docs.your_domain, including for OPTIONS preflight requests and the X-API-Key header. Configure that in the API or its reverse proxy, not in the docs server.
The page still shows the Petstore demo: the browser cached the old swagger-initializer.js. Force a reload, and confirm that you edited the file under /var/www/api-docs/swagger/.
openapi-spec-validator: command not found: ~/.local/bin is not in your PATH yet. Open a new shell after pipx ensurepath, or run ~/.local/bin/openapi-spec-validator directly.
Conclusion
You wrote and validated an OpenAPI specification, self-hosted Swagger UI and ReDoc, served them from Nginx over HTTPS with correct headers for the spec files, and published two API versions side by side. From here you can generate the spec from your framework (FastAPI, NestJS and Spring all export OpenAPI) instead of writing it by hand, run openapi-spec-validator in CI before copying new specs to the server with rsync, and generate client SDKs from the same file with OpenAPI Generator.
