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 sudo privileges.
  • 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:

PathContent
/var/www/api-docs/specs/v1/openapi.yamlThe 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.