← Blog
Publié le
2026-10-28

FastAPI deployment on a CPU VM: Docker, HTTPS, and load testing

A CPU virtual machine gives you control over how a FastAPI application starts, receives traffic, and recovers when its process fails. That control also makes you responsible for the operating system, application updates, access controls, and monitoring.

This walkthrough builds a small authenticated API, runs it with Docker Compose, and provides checks for private access, public HTTPS, restarts, and load. The example adds a short list of integers. It is deliberately small enough to inspect, but it is not a benchmark for your application or a complete production service.

Validation status, October 30, 2026: The application passed 12 local checks on Windows with Python 3.12.14, and Docker Compose 5.1.4 parsed the configuration successfully. The linked setup and security documentation was rechecked on the same date. The Docker image, Linux deployment, Hivenet endpoint, HTTPS path, restart and VM-recovery behavior, rollback, and k6 load test have not been run. The deployment steps below are documentation-based; cloud response examples are expected results, and load thresholds are illustrative.

If you are still choosing an operating model, the API-hosting guide compares CPU VMs, managed platforms, and serverless services. This walkthrough covers the self-managed VM path.

‍

Check the VM and access requirements

Use a Linux CPU VM where you can install and operate Docker Engine. A managed container session may not provide the same permissions. Hivenet's Docker VM guide explains this distinction. Confirm the instance type and access method in your account before starting.

You will need:

  • A disposable test VM, its actual SSH connection details, and permission to run Docker.
  • A maintained Docker Engine release, version 28 or later, with a current docker compose plugin. Older Docker releases have a documented caveat around localhost port exposure.
  • Python 3 on the VM to generate a test credential, plus curl for requests.
  • A separate machine with k6 installed if you want to run the load-test procedure.
  • An approved HTTPS route before sending a credential over a public network.

The file-permission commands assume ordinary rootful Docker without user-namespace remapping. Rootless Docker or a remapped installation needs permissions appropriate to its own user mapping. Check that before copying the commands.

This example needs no GPU. Start with a CPU configuration that you can change after testing. The CPU VM buying guide provides the wider workload context; it does not establish how many requests this application can serve.

Create a small FastAPI application

Create an empty project directory on the test VM. Save the following as app.py.

import os
import secrets
from pathlib import Path
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, Field

SmallInt = Annotated[int, Field(strict=True, ge=-1_000_000, le=1_000_000)]


class SumRequest(BaseModel):
    values: list[SmallInt] = Field(min_length=1, max_length=100)


def create_app() -> FastAPI:
    token_path = Path(os.environ.get("API_TOKEN_FILE", "secrets/api_token"))
    expected = token_path.read_text(encoding="utf-8").strip().encode("utf-8")
    if len(expected) < 32:
        raise RuntimeError("Provide an API token of at least 32 bytes")

    api = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
    key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

    async def authenticate(key: str | None = Depends(key_header)) -> None:
        if key is None or not secrets.compare_digest(key.encode("utf-8"), expected):
            raise HTTPException(status_code=401, detail="Invalid API key")

    @api.get("/healthz")
    async def health() -> dict[str, str]:
        return {"status": "ok"}

    @api.post("/sum", dependencies=[Depends(authenticate)])
    async def add(payload: SumRequest) -> dict[str, int]:
        return {"total": sum(payload.values), "count": len(payload.values)}

    return api

The API key protects the /sum endpoint. A request can contain between one and 100 integers, each between minus one million and one million. Strings, booleans, and decimal values are rejected. These are limits on parsed input, not a limit on raw HTTP request size; set an appropriate body-size limit at the public proxy before using this pattern with untrusted traffic.

The unauthenticated /healthz route reveals only a status value. It checks that the process can answer, not that a database or another dependency is healthy. Interactive documentation is disabled to keep the example small. Disabling documentation does not secure an API.

One shared test key is enough for this exercise. A customer-facing service needs an authentication and authorization design that reflects its users, permissions, and credential lifecycle.

Save this dependency snapshot as requirements.lock. These versions supported the local application checks on October 28 and October 30, 2026. They have not been installed or tested inside the Linux image. Revalidate the dependencies when updating them.

‍

annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
click==8.5.0
fastapi==0.162.0
h11==0.16.0
idna==3.20
opentelemetry-api==1.45.1
pydantic==2.14.1
pydantic_core==2.50.1
starlette==1.10.0
typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.54.0

Build the Docker image and Compose service

Save this Dockerfile in the same directory.

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
RUN useradd --uid 10001 --create-home appuser
COPY app.py .
USER 10001:10001
EXPOSE 8888
CMD ["python", "-m", "uvicorn", "app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8888", "--workers", "1", "--no-proxy-headers", "--timeout-graceful-shutdown", "20"]

The container runs as an unprivileged user. Uvicorn loads the application factory, listens inside the container on port 8888, and starts one worker. There is no development reload process. FastAPI's container deployment documentation recommends building from a Python image; the older prebuilt FastAPI/Gunicorn image is deprecated.

The Python base-image tag can change. For a repeatable deployment, record the resolved base-image digest and built application-image ID, retain the tested image, and deploy that same artifact. A dependency list alone does not freeze the operating-system packages in the image.

Save the following as compose.yaml.

services:
  api:
    build: .
    image: fastapi-cpu-demo:${APP_VERSION:-v1}
    restart: unless-stopped
    ports:
      - "${BIND_IP:-127.0.0.1}:8888:8888"
    environment:
      API_TOKEN_FILE: /run/secrets/api_token
    secrets:
      - api_token
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    stop_grace_period: 30s
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8888/healthz', timeout=2)"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
secrets:
  api_token:
    file: ./secrets/api_token

The host port defaults to 127.0.0.1, so the first checks stay local to the VM. Docker explains the behavior and older-release caveat in its port-publishing documentation. Keep that private default until you have checked the public route.

The read-only container filesystem, temporary /tmp, dropped capabilities, and limited log files keep the example's operating needs explicit. The application has no persistent data. Compose mounts the API key as a file; do not bake it into the image or commit it to a repository.

Save this .dockerignore to keep local secrets and test artifacts out of the build context.

.git
.env
secrets
.venv
__pycache__
.pytest_cache
tests
*.json

Start privately and check the responses

Run these commands once in the new project directory. The key is generated locally and never printed. The host file is readable by the container's numeric user ID, 10001. File-backed Compose secrets use a bind mount, so setting ownership on the host matters; see the Compose secrets reference.

umask 077
mkdir -p secrets
chmod 700 secrets
python3 -c 'import secrets; print(secrets.token_urlsafe(32))' | sudo tee secrets/api_token >/dev/null
sudo chown 10001:10001 secrets/api_token
sudo chmod 400 secrets/api_token
docker compose config --quiet
docker compose up -d --build
docker compose ps
curl --fail-with-body http://127.0.0.1:8888/healthz

The expected health response is {"status":"ok"}. If the service does not start, inspect docker compose logs --tail=100 api. A missing or unreadable secret should be corrected at the file-permission boundary; do not solve it by running the application as root.

Then make an authenticated request from the VM:

API_TOKEN="$(sudo cat secrets/api_token)"
curl --fail-with-body http://127.0.0.1:8888/sum \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_TOKEN" \
  --data '{"values":[1,2,3]}'
unset API_TOKEN

The expected response is {"total":6,"count":3}. Repeat without the key and expect HTTP 401. With a valid key but an empty list, expect HTTP 422. Keep keys out of source code, screenshots, logs, and saved request examples.

To reach the private service from your own machine, keep an SSH tunnel open:

ssh -N -L 8888:127.0.0.1:8888 YOUR_SSH_USER@YOUR_SSH_HOST

Replace the user and host with the console's actual connection details and include any required key or SSH-port options. Requests to http://127.0.0.1:8888 on your machine then travel through the tunnel. This gives you encrypted transport through SSH; it does not create a public HTTPS endpoint.

Verify the public HTTPS path separately

Hivenet's connectivity documentation describes a public route for a service listening on port 8888. Confirm that this route applies to your selected VM and obtain its exact address from the console. Do not construct a hostname from an instance ID or assume a managed-container example applies to a VM.

Keep the service private if the route is unavailable or unclear. If the platform requires a service reachable on the VM's network interface, first confirm how direct access to the unencrypted port is restricted. Only then create a local .env file containing:

BIND_IP=0.0.0.0
APP_VERSION=v1

Run docker compose up -d to apply the changed binding. Exposing port 8888 on all interfaces is a network-access change: the intended public entry point should be HTTPS, with the backend port restricted to the approved proxy path. Do not disable a firewall to make the example work. If you cannot establish that boundary, continue with the private SSH tunnel.

Close the SSH tunnel and check the actual HTTPS address from another machine. Use normal certificate validation, without curl's -k option. Check /healthz, an authenticated /sum request, and a request with a missing key. Confirm the certificate's hostname, expected responses, and that the raw HTTP backend is not publicly reachable. Do not send the key to a plain HTTP address or follow an unexpected redirect.

HTTPS normally terminates at a proxy before reaching the application; FastAPI explains this in its HTTPS deployment guide. This example starts Uvicorn with proxy-header trust disabled. If your application needs the original scheme or client address for redirects or logging, configure the known trusted proxy addresses using Uvicorn's settings. Do not trust forwarded headers from every source by default.

A custom domain, DNS records, certificate renewal, and proxy timeouts need their own configuration and checks. A successful local response establishes none of those things.

Check restarts, health, and rollback

The Compose service uses restart: unless-stopped. Docker's restart-policy documentation distinguishes an unexpected process exit from an intentional stop. The policy takes effect after the container has started successfully, including Docker's initial ten-second monitoring period.

On the disposable test VM, after the service has been running for at least ten seconds, you can deliberately terminate its main process:

docker compose exec api python -c 'import os, signal; os.kill(1, signal.SIGTERM)'
docker compose ps
docker inspect --format '{{.RestartCount}}' "$(docker compose ps -q api)"
curl --fail-with-body http://127.0.0.1:8888/healthz

The termination command may return an error because it ends the container process. Record whether Docker restarts it, how long requests fail, and whether authenticated requests work afterward. This procedure has not been executed for this article.

An unhealthy status alone does not restart a still-running process under this configuration. Monitor both process exits and failed health checks. The Compose health check also does not prove that external clients can reach the service.

A container restart is not recovery from loss of the VM. Separately verify Docker startup after a permitted VM reboot, retained files, secret availability, and the external endpoint. Re-creating a lost VM requires the image, configuration, credentials, and any data backups to exist outside that VM.

For application changes, retain the previous image under its existing version tag. Build a new tag, then start it without an implicit rebuild:

APP_VERSION=v2 docker compose build
APP_VERSION=v2 docker compose up -d --no-build

If the new version fails the checks, restore the retained image with APP_VERSION=v1 docker compose up -d --no-build. After accepting a release, update APP_VERSION in .env so later commands use the intended version. This single-container procedure can interrupt requests; it does not provide a zero-downtime rollout. Rolling back an image also does not undo a database migration.

Run a load test against the real workload

Use a separate load-generator machine so it does not compete with the API for the same CPU and memory. Save this as load.js and use only an endpoint you are authorized to test.

import http from 'k6/http';
import { check } from 'k6';

if (!__ENV.BASE_URL || !__ENV.API_TOKEN) throw new Error('Set BASE_URL and API_TOKEN');
const base = __ENV.BASE_URL.replace(/\/$/, '');
export const options = {
  scenarios: {
    api: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.RATE || 5),
      timeUnit: '1s',
      duration: __ENV.DURATION || '30s',
      preAllocatedVUs: 10,
      maxVUs: 30,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<300'],
    checks: ['rate==1'],
    dropped_iterations: ['count==0'],
  },
};
export default function () {
  const response = http.post(`${base}/sum`, JSON.stringify({ values: [1, 2, 3] }), {
    headers: { 'Content-Type': 'application/json', 'X-API-Key': __ENV.API_TOKEN },
    timeout: '5s',
    redirects: 0,
  });
  check(response, {
    'correct response': r => {
      if (r.status !== 200) return false;
      try { return r.json('total') === 6 && r.json('count') === 3; }
      catch { return false; }
    },
  });
}

export function handleSummary(data) {
  const summary = JSON.stringify(data, null, 2);
  return { 'results.json': summary, stdout: summary + '\n' };
}

The example requests five iterations per second for 30 seconds. Each iteration makes one request. Its p95 latency limit of 300 milliseconds and error-rate limit of 1% are illustrative acceptance thresholds, not measured results or promises about Hivenet. Replace them with requirements before evaluating a deployment.

Provide BASE_URL as the verified HTTPS origin and API_TOKEN through the load generator's secret handling. Do not commit either a real key or an output file containing secrets. Then run:

k6 run load.js

The constant-arrival-rate executor schedules work independently of response time. Dropped iterations mean it did not sustain the requested schedule, so this example fails that condition rather than reporting the target rate as achieved. The k6 threshold documentation explains the pass/fail mechanism.

The script uses k6's custom summary function to write aggregated results to results.json and print the same JSON in place of the default console summary. Run each test in its own directory or save that file before the next run, which overwrites it. This summary code has been checked locally with synthetic input; it has not been executed by k6 or against a cloud endpoint.

‍

Run a separate warm-up, then normal traffic, an expected peak, and a longer steady run. Change RATE and DURATION deliberately and record them. Collect achieved request rate, latency percentiles, request errors, failed response checks, dropped iterations, CPU use, memory use, and application logs. docker stats is a useful starting point for container resource use.

The arithmetic endpoint mainly checks the request path. Replace it with representative application work and realistic data before sizing a VM. A database-backed API needs tests that include connection-pool limits, slow queries, dependency failures, and timeouts. Long-running background jobs need an appropriate worker and durable queue design rather than an assumption that an HTTP process will retain every job.

Start with one Uvicorn worker and change one variable at a time. Additional workers consume memory and create their own application state and database connections. A higher worker count should follow measurements, not a rule that equates workers with advertised vCPUs. The vCPU vs. physical core guide explains why an advertised vCPU count alone does not establish application capacity.

Use the results to choose CPU capacity

Keep a deployment record with the VM configuration and location, software versions, image IDs, test input, traffic settings, observed results, restart outcomes, and test date. Mark any unexecuted check as untested. A source-based walkthrough can define a useful procedure, but it cannot supply performance or recovery evidence.

Compare the full running cost once the workload meets its requirements. Include compute, storage, network transfer, monitoring, and the time needed to maintain the service. The compute-pricing guide gives that comparison a consistent starting point.

If you want to assess a CPU deployment on Hivenet, contact the team with your workload, location, access requirements, and measured or expected traffic. Confirm current capacity and the supported deployment path before planning a production move.

‍

Votre prochaine charge de travail sur Hivenet.

Choisissez une charge de travail d’IA, de calcul ou de stockage à tester sur Hivenet, ou échangez avec notre équipe sur sa mise en production.