Reading Server
    Docker Compose

    Deploy Kavita on a VPS

    Self-host Kavita, a lightweight manga, comics, and ebook reading server, on a RamNode VPS with Docker Compose, read-only library mounts, and Nginx TLS.

    Kavita is a self-hosted reading server for manga, comics, webtoons, and ebooks. It reads CBZ, CBR, CB7, ZIP, RAR, EPUB, and PDF directly, serves them through a fast web reader that works on phones and tablets, tracks reading progress per user, and exposes an OPDS feed so external reader apps can browse and download from your library. It is built on .NET and is noticeably lighter on memory than JVM-based alternatives.

    This guide deploys Kavita on a single RamNode VPS with Docker Compose, the container bound to localhost, Nginx terminating TLS, and the library mounted read-only into the container.

    Architecture Overview

    • Kavita container. A .NET application serving the web UI, the REST API, the OPDS feed, and a SignalR hub that pushes scan progress and library updates to open browser sessions.
    • /kavita/config. Holds the SQLite database, appsettings.json, cached cover images, logs, and Kavita's own scheduled backups. This path is fixed. The image expects it exactly there.
    • Library mounts. One host directory per content type. This guide uses /srv/media/manga, /srv/media/comics, and /srv/media/books.
    • Nginx. Handles TLS and proxies HTTP and WebSocket traffic to 127.0.0.1:5000.

    Kavita never needs to write to your library files. It stores covers and metadata in its own config directory. That lets you mount the library read-only, which protects your files from any bug in a scan or a cleanup task.

    What You Will Need

    • A RamNode VPS running Ubuntu 24.04 LTS. 1 vCPU and 2 GB RAM handles a large personal library. Kavita idles at a few hundred MB. Library scans and cover generation are the CPU-heavy moments, and they only run on change or on schedule.
    • Disk sized for your library. Manga volumes are typically 50 to 200 MB each as CBZ. If your plan supports additional block storage volumes, put media there.
    • Root or sudo access.
    • A domain or subdomain with an A record pointing at the VPS. This guide uses kavita.example.com.

    Step 1: Prepare the System

    shell
    apt update && apt upgrade -y
    apt install -y ca-certificates curl gnupg ufw rsync
    timedatectl set-timezone America/New_York

    Add swap on 2 GB plans. The first scan of a large library generates thousands of cover thumbnails and benefits from the headroom.

    shell
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab

    Optional: Mount a Block Storage Volume for Media

    shell
    lsblk
    mkfs.ext4 /dev/vdb
    mkdir -p /srv/media
    echo "UUID=$(blkid -s UUID -o value /dev/vdb) /srv/media ext4 defaults,nofail 0 2" >> /etc/fstab
    mount -a
    df -h /srv/media

    Keep /opt/kavita/config on the root disk. The SQLite database should sit on local storage, never a network share.

    Step 2: Install Docker

    shell
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc
    
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
    https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      > /etc/apt/sources.list.d/docker.list
    
    apt update
    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    systemctl enable --now docker

    Cap container log size.

    shell
    cat > /etc/docker/daemon.json <<'EOF'
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    EOF
    systemctl restart docker

    Step 3: Create the Directories

    shell
    mkdir -p /opt/kavita/config
    mkdir -p /srv/media/{manga,comics,books}

    Because the library is mounted read-only, the container only needs read access to it. World-readable directories are enough.

    shell
    chmod -R a+rX /srv/media

    Step 4: Write the Compose File

    shell
    pico /opt/kavita/compose.yaml
    shell
    services:
      kavita:
        image: jvmilazz0/kavita:latest
        container_name: kavita
        environment:
          - TZ=America/New_York
        ports:
          - "127.0.0.1:5000:5000"
        volumes:
          - /opt/kavita/config:/kavita/config
          - /srv/media/manga:/manga:ro
          - /srv/media/comics:/comics:ro
          - /srv/media/books:/books:ro
        restart: unless-stopped

    Notes on the choices here:

    • 127.0.0.1:5000:5000. Docker's published ports bypass UFW. Binding to loopback keeps the unencrypted port off the public interface.
    • :ro on library mounts. Kavita does not write to library files. Read-only mounts cost nothing and remove a whole class of accidents.
    • /kavita/config must not change. The container path is hard-coded in the image. Change the host side freely.
    • Pin a version. For production, replace latest with a release tag from the Kavita GitHub releases page. A nightly tag also exists. Do not use it on a server other people rely on.

    Start it.

    shell
    cd /opt/kavita
    docker compose up -d
    docker compose logs -f

    Wait for the startup line showing Kavita listening on port 5000, then exit the log stream and check it locally.

    shell
    curl -sI http://127.0.0.1:5000 | head -1

    Step 5: Configure the Firewall

    shell
    apt install -y nginx certbot python3-certbot-nginx
    ufw allow OpenSSH
    ufw allow 'Nginx Full'
    ufw enable
    ufw status

    Step 6: Configure the Nginx Reverse Proxy

    shell
    pico /etc/nginx/sites-available/kavita
    shell
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
    
    server {
        listen 80;
        listen [::]:80;
        server_name kavita.example.com;
    
        client_max_body_size 100M;
    
        location / {
            proxy_pass http://127.0.0.1:5000;
            proxy_http_version 1.1;
    
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
    
            # SignalR hub for live scan progress and library updates.
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
    
            proxy_read_timeout 300s;
        }
    }

    Enable it and issue a certificate.

    shell
    ln -s /etc/nginx/sites-available/kavita /etc/nginx/sites-enabled/
    rm -f /etc/nginx/sites-enabled/default
    nginx -t && systemctl reload nginx
    
    certbot --nginx -d kavita.example.com --redirect -m you@example.com --agree-tos --no-eff-email
    systemctl list-timers | grep certbot

    Serving Kavita Under a Subpath Instead

    A dedicated subdomain is the simplest setup. If you need example.com/kavita instead, set the Base URL in Server Settings > General to /kavita/, restart the container, and change the Nginx location to location /kavita/. Kavita stores the base URL in /opt/kavita/config/appsettings.json. Edit it there if you lock yourself out of the UI after a wrong value.

    Step 7: First-Run Setup

    Browse to https://kavita.example.com. The first visit creates the admin account. Use a strong password and a real email address.

    Create libraries under Server Settings > Libraries > Add Library:

    1. Name it and choose the type. Manga and Comic use the image reader. Book uses the EPUB reader and treats each file as a standalone volume. Pick the type that matches the content, because it changes how Kavita parses filenames.
    2. Add the folder by its container path: /manga, /comics, or /books.
    3. Save. Kavita starts the first scan immediately.

    Create regular users for daily reading under Server Settings > Users and grant each one only the libraries they should see. Age rating restrictions are available per user if anyone on the server is a child.

    Step 8: Load Your Library

    Kavita requires a series folder layer. Files dropped directly into a library root will not scan correctly.

    shell
    /srv/media/manga/
    ├── Berserk/
    │   ├── Berserk v01.cbz
    │   ├── Berserk v02.cbz
    │   └── Berserk v03.cbz
    └── One Punch Man/
        ├── One Punch Man ch 001.cbz
        └── One Punch Man ch 002.cbz
    
    /srv/media/books/
    └── Brandon Sanderson/
        ├── Mistborn - The Final Empire.epub
        └── Mistborn - The Well of Ascension.epub

    Volume and chapter numbers come from the filename. v01, Vol. 1, ch 001, and Chapter 1 all parse. If a series has inconsistent naming, Kavita will still group the files by folder, but sort order may be off until you clean the names. Embedded ComicInfo.xml inside a CBZ takes priority over filename parsing when present.

    Upload from your workstation with rsync.

    shell
    rsync -avP --partial ~/Manga/ root@your-vps-ip:/srv/media/manga/

    Then make the new files readable and let the folder watcher pick them up, or run Scan Library from the library's menu.

    shell
    chmod -R a+rX /srv/media/manga

    Step 9: OPDS and Reader Apps

    Every Kavita user has a personal OPDS URL containing their API key. Find it under User Settings > 3rd Party Clients. It looks like this:

    shell
    https://kavita.example.com/api/opds/<your-api-key>

    Paste the full URL into any OPDS-capable reader, such as Panels, Chunky, KOReader, or Moon+ Reader. Treat that URL like a password. Anyone holding it can browse and download from that user's libraries. Rotate the key from the same screen if it leaks.

    Email Configuration

    Kavita uses email for invites, password resets, and send-to-device. Since v0.7.14 SMTP is configured directly in Server Settings > Email, with no separate relay container needed.

    RamNode does not permit mail services on its VPS plans and may restrict outbound SMTP ports. Point Kavita at a transactional mail relay rather than a local MTA, and if port 587 is blocked on your account, use a relay that offers an alternate submission port such as 2525. Email is optional. Without it, you invite users by generating a link in the admin UI and sending it yourself.

    Backups

    Kavita runs its own scheduled backup task. Configure frequency and retention under Server Settings > Tasks. Archives land in /opt/kavita/config/backups. Copy them off the server.

    shell
    rsync -avP root@your-vps-ip:/opt/kavita/config/backups/ /backups/kavita/

    For a full cold backup including the cover cache:

    shell
    cd /opt/kavita
    docker compose stop
    tar czf /root/kavita-config-$(date +%F).tar.gz config
    docker compose start

    The library itself is not in these backups. Keep your originals elsewhere.

    Upgrading

    shell
    cd /opt/kavita
    docker compose pull
    docker compose up -d
    docker image prune -f

    If you pinned a tag, update it in compose.yaml first. Kavita migrates its database automatically on startup and the migration does not run backward. Take a backup before every upgrade and read the release notes, which call out breaking changes clearly.

    Troubleshooting

    Library scans but shows zero series. Files are sitting directly in the library root. Move them into one folder per series and rescan.

    One series splits into several, or volumes appear out of order. Inconsistent filenames. Check that every file in the folder uses the same series name and volume or chapter pattern. A ComicInfo.xml with the correct Series field also fixes grouping.

    Scan progress bar never moves and pages need a manual refresh. The SignalR WebSocket is not passing through the proxy. Confirm the Upgrade and Connection headers survived Certbot's edit with nginx -T | grep -A2 Upgrade.

    Permission denied in the logs during a scan. The container cannot read the file. Run chmod -R a+rX on the library path, then rescan.

    Locked out after changing Base URL. Edit BaseUrl in /opt/kavita/config/appsettings.json back to /, then docker compose restart.

    High CPU right after adding a large batch of files. Cover generation. It is a one-time cost per file and settles once the scan finishes.

    Hardening Notes

    • Confirm the container is only on loopback: ss -tlnp | grep 5000 should show 127.0.0.1.
    • Keep library mounts read-only.
    • Treat OPDS URLs as credentials. Rotate API keys for users who leave.
    • Use key-based SSH and disable password login.
    • For a private family server, place it behind WireGuard or Tailscale and drop the public DNS record.

    Next Steps

    Kavita and Komga cover much of the same ground. Kavita is lighter on memory and handles EPUB-heavy collections well. Komga has deeper comic metadata editing and Kobo sync. Both have their own RamNode guides, and nothing stops you running both against the same read-only library to compare. For audiobooks and podcasts, add Audiobookshelf alongside either one on a separate subdomain.