Skip to content
FreeToolNet
Go back

Running a GPU-first Gradio app CPU-only on a 2 vCPU VPS

I wanted to run whiteboard-animation-vi on my VPS. It is a Gradio app that turns an illustration into a whiteboard-drawing MP4 with a moving hand. Upstream defaults to CUDA for rendering and NVENC for encoding. My VPS has 2 vCPUs and no GPU.

It works. The default render (10 seconds, 1920×1072, 30 fps) takes about 40 seconds and peaks at about 1.4 GB of RAM. Upstream’s 28 tests pass inside the image. Getting there took four changes to the image and one confusing error.

1. Install CPU torch before the requirements file

Upstream’s requirements.txt pins torch==2.8.0. A plain pip install -r requirements.txt would pull the default wheel with the CUDA libraries, which are large and useless here.

The fix is ordering. Install the CPU wheels first from PyTorch’s CPU index. When pip later reads the requirements file, the pin is already satisfied and it downloads nothing.

FROM python:3.12-slim

ARG REPO=https://github.com/letuandat131/whiteboard-animation-vi.git
ARG REF=c689bf7fd75fb4cb84f6ae2880a4dac6f40b92d0

ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 GRADIO_ANALYTICS_ENABLED=False

# libgl1/libglib2.0-0: opencv-python (non-headless) needs them at import time
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg git libgl1 libglib2.0-0 \
    && rm -rf /var/lib/apt/lists/*

# CPU wheels first, so requirements.txt's torch==2.8.0 pin is already satisfied (no CUDA download)
RUN pip install torch==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cpu

WORKDIR /app
RUN git clone "$REPO" . && git checkout --detach "$REF" && rm -rf .git
RUN pip install -r requirements.txt

Two more things in that block:

  • The upstream repo is pinned to a full commit hash. The next step patches a source file with sed. A pinned commit means the patch target does not move under me.
  • libgl1 and libglib2.0-0. The app uses the non-headless opencv-python, which needs these at import time. On a slim base image they are missing.

2. Bake the model into the image

The app needs an ISNet checkpoint of about 204 MB. Upstream ships a download script that verifies it by SHA256. I run that script at build time, so the container never downloads anything on first use:

# ISNet checkpoint (~204 MB, SHA256-verified by the script); rendering never downloads it implicitly.
# The script writes it via a 0600 temp file, so open it up for the non-root app user.
RUN python scripts/download_model.py && chmod 644 assets/v181d/model.safetensors

That chmod is the fix for the confusing error.

The gotcha: “No such file” for a file that exists

Without the chmod, the model file is in the image at the right path, and the app still fails to load it. The error from safetensors says the file does not exist.

The file existed. The real problem was permissions:

  • The download script writes the model through a temp file with mode 0600.
  • The container runs as a non-root user, which cannot read that file.
  • safetensors reports this as “No such file” instead of a permission error.

So the error message points at the wrong thing. If a model file is clearly on disk and the loader says it is missing, check the mode and owner before checking the path. One chmod 644 in the Dockerfile fixed it.

3. Make CPU the default in the UI

Upstream’s settings default the rendering device to cuda and the encoder to nvenc. Neither exists on this server, so I patch the two defaults at build time:

# Upstream defaults to CUDA + NVENC; there's no GPU here, so make CPU the default in the UI
RUN sed -i -e "s/control('cuda', 'Rendering device'/control('cpu', 'Rendering device'/" \
           -e "s/control('nvenc', 'Encoder'/control('cpu', 'Encoder'/" handdraw/settings.py \
    && grep -q "control('cpu', 'Rendering device'" handdraw/settings.py \
    && grep -q "control('cpu', 'Encoder'" handdraw/settings.py

The two grep -q lines are the important part. sed exits successfully even when it replaces nothing. If I later bump the pinned commit and upstream has changed that line, sed would silently do nothing and I would ship an image that defaults to CUDA again. With the grep checks, the build fails instead.

4. A launcher that adds a login

Upstream’s entry point is meant for a local machine. For a public server I need it bound to all interfaces inside the container, and I need a login. Upstream exposes a build_ui() function, so I wrote a small launcher that calls it and changes only the launch options:

from app import OUTPUT_DIR, ROOT, build_ui


def main():
    logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    demo = build_ui()
    # Gradio also copies each result into /tmp/gradio; drop those after a week, like outputs/
    demo.delete_cache = (86400, 7 * 86400)
    # Same launch options as upstream app.main(), except the bind address, port and auth
    demo.launch(server_name='0.0.0.0', server_port=7860, share=False,
                      auth=(os.environ['WB_USER'], os.environ['WB_PASSWORD']),
                      allowed_paths=[str(OUTPUT_DIR.resolve())],
                      blocked_paths=[str(ROOT / 'logs'), str(ROOT / 'assets' / 'v181d' / 'model.safetensors')],
                      max_file_size='20mb', footer_links=[],
                      theme=gr.themes.Default(primary_hue='orange', neutral_hue='slate', font=['Segoe UI', 'sans-serif']),
                      css_paths=ROOT / 'assets' / 'studio.css')

auth= turns on Gradio’s built-in login page. The username and password come from an env file on the host that only root can read. No upstream file is edited for this. The launcher is copied in next to the app.

5. Cap the memory

A render peaks at about 1.4 GB. This VPS has no swap and also runs n8n and other services. A runaway render must not take them down.

# The memory cap keeps a heavy render from starving n8n and the other services (no swap on this box).
docker run -d --name whiteboard --restart unless-stopped --memory 3g --pids-limit 512 \
  --network $NET -p 127.0.0.1:$PORT:7860 --env-file /etc/whiteboard.env \
  -v /srv/whiteboard/outputs:/app/outputs $IMG >/dev/null

With --memory 3g, the worst case is that this one container is killed and restarted. The port is bound to 127.0.0.1, so the only way in is through nginx.

6. Clean up renders in two places

Upstream never deletes its output. On a small disk that fills up. There are two copies of every video to deal with:

  • The app writes to outputs/, which is a host folder. A daily cron job deletes files older than a week.
  • Gradio also copies each result into /tmp/gradio. The delete_cache line in the launcher drops those after a week too.
cat > /etc/cron.daily/whiteboard-outputs <<'EOF'
#!/bin/sh
find /srv/whiteboard/outputs -type f -mtime +7 -delete
EOF
chmod 755 /etc/cron.daily/whiteboard-outputs

The deploy script also runs docker image prune -f and docker builder prune -f after each build. Every rebuild of an image this size leaves an untagged copy and build layers behind.

7. nginx for a Gradio app

    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:8600;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Gradio streams queue/progress updates over SSE
        proxy_buffering off;
        proxy_read_timeout 1800s;
        proxy_send_timeout 1800s;
    }
  • proxy_buffering off. Gradio sends queue and progress updates as server-sent events. With buffering on, nginx holds them back.
  • Long read timeout. A render is one long request from nginx’s point of view.
  • client_max_body_size 25m. The launcher limits uploads to 20 MB. nginx sits a little above that, so the app’s own limit is the one users hit.

/login has its own location with a rate limit of 10 requests a minute and a burst of 5, to slow down password guessing.

Two things that look like bugs and are not

Progress sits at 1%. During a render the progress bar in the UI stays at 1% until the video is finished. On CPU that is about 40 seconds of apparent nothing. It is normal.

The first start is slow. Importing torch and Gradio takes a while. My deploy script does not check once and give up. It polls:

# Checks (torch + Gradio take a while to import on first start)
for _ in $(seq 1 30); do
  [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$PORT/)" = 200 ] && break; sleep 3
done

The Dockerfile’s HEALTHCHECK uses --start-period=60s for the same reason.

One last deploy note. I edit these files on Windows and copy them to the server. Shell scripts with CRLF line endings fail on Linux, so I strip CRLF before running the script there.

Was it worth running on CPU?

For this app, yes. About 40 seconds for a 10-second clip at 1920×1072 is fine for me. The work was all in packaging: CPU wheels in the right order, the model baked in and readable, CPU defaults that fail the build if the patch stops applying, and a memory cap so a render cannot hurt the rest of the server.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-02 on Hostinger VPS, 2 vCPU, no GPU, no swap; Docker container behind nginx with a certbot certificate (python:3.12-slim, torch 2.8.0 (CPU wheel), torchvision 0.23.0 (CPU wheel), whiteboard-animation-vi at commit c689bf7).



Previous Post
A single-user notes app on FastAPI with files in an S3 bucket
Next Post
Deploy a Docker site to a VPS with GitHub Actions, GHCR and SSH