Python Notebook
    Token Auth

    Deploy marimo on a VPS

    Self-host marimo reactive Python notebooks on a RamNode VPS — app mode and edit mode deployments with Docker, token auth, Caddy TLS, and backups.

    marimo is an open source reactive Python notebook. Notebooks are stored as pure Python files, run reproducibly, and deploy as either an interactive edit server or a read only web app. This guide covers a production deployment on a RamNode KVM VPS running Ubuntu 24.04 LTS, with token authentication, host hardening, a reverse proxy with automatic TLS, and a backup plan.

    Choose your deployment mode

    marimo serves two distinct things, and the right choice shapes everything that follows:

    • App mode (marimo run) serves a notebook as a polished, read only web app. Code cells are hidden, users interact only with widgets and outputs. This is the safe default for sharing a dashboard or tool.
    • Edit mode (marimo edit) is a full remote authoring environment. Anyone who reaches it can write and execute arbitrary Python on your server. Treat an edit server as equivalent to handing out a shell. Only run it behind strong authentication and ideally restrict it to your own IPs.

    Both modes are covered below. Containerised app mode is recommended for anything user facing; edit mode is for a private personal notebook server.

    Recommended RamNode sizing

    marimo itself is light. Size for the libraries and data your notebooks load.

    WorkloadvCPURAMDisk
    Light apps, small data11 to 2 GB25 GB
    Pandas / numpy heavy notebooks24 GB40 GB+

    Prerequisites

    • A RamNode KVM VPS with Ubuntu 24.04 LTS.
    • A domain or subdomain (for example notebook.example.com) with an A record pointing at the VPS public IP.
    • SSH access as root or a sudo user.
    • Your notebook saved as a Python file, for example app.py, plus a requirements.txt listing its dependencies.

    Step 1: Host hardening

    Create a non-root sudo user:

    shell
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    Confirm key based login as deploy, then harden SSH in /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no
    shell
    sudo systemctl reload ssh

    Configure the firewall. marimo runs behind a reverse proxy, so only SSH, HTTP, and HTTPS are public:

    shell
    sudo apt update && sudo apt -y install ufw fail2ban
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable

    Enable automatic security updates:

    shell
    sudo apt -y install unattended-upgrades
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    Step 2: Install Docker

    shell
    sudo apt -y install ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    sudo 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" | \
      sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo usermod -aG docker deploy

    Log out and back in for the group change.

    Step 3: Build the app image

    Set up the project:

    shell
    sudo mkdir -p /var/lib/marimo
    sudo chown -R deploy:deploy /var/lib/marimo
    cd /var/lib/marimo

    Place your app.py and requirements.txt here. The requirements.txt must list marimo plus any libraries your notebook imports, for example:

    shell
    marimo
    pandas
    numpy
    altair

    Create /var/lib/marimo/Dockerfile. This uses uv for fast installs and runs as a non-root user inside the container:

    shell
    # syntax=docker/dockerfile:1.4
    FROM python:3.11-slim
    
    # uv for fast package management
    COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
    ENV UV_SYSTEM_PYTHON=1
    
    WORKDIR /app
    
    COPY --link requirements.txt .
    RUN uv pip install -r requirements.txt
    
    COPY --link app.py .
    
    EXPOSE 8080
    
    # Run as a non-root user inside the container
    RUN useradd -m app_user
    USER app_user
    
    # App mode: read only, code hidden. Token auth is enabled via env at runtime.
    CMD ["marimo", "run", "app.py", "--host", "0.0.0.0", "-p", "8080"]

    If you would rather not maintain a Dockerfile, the marimo team publishes prebuilt images at ghcr.io/marimo-team/marimo:latest (and a latest-sql variant). The Dockerfile approach is preferred here because it bakes your dependencies in and produces a reproducible artifact.

    Step 4: Run with token authentication

    marimo enables token authentication by default. For a deterministic, repeatable token in app mode, pass your own with --token-password. Supply it as an environment variable rather than baking it into the image.

    Create /var/lib/marimo/docker-compose.yml:

    shell
    services:
      marimo:
        container_name: marimo
        build: .
        restart: unless-stopped
        command: >
          marimo run app.py
          --host 0.0.0.0 -p 8080
          --token --token-password=${MARIMO_TOKEN}
        ports:
          - "127.0.0.1:8080:8080"
        healthcheck:
          test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
          interval: 30s
          timeout: 3s
          retries: 3

    Generate a token and store it in /var/lib/marimo/.env:

    shell
    echo "MARIMO_TOKEN=$(openssl rand -hex 24)" > /var/lib/marimo/.env

    Build and start:

    shell
    cd /var/lib/marimo
    docker compose up -d --build
    docker compose logs -f

    Binding port 8080 to 127.0.0.1 keeps it private to the reverse proxy. marimo exposes /health, /healthz, and /api/status endpoints, which the healthcheck above uses.

    Step 5: Reverse proxy with automatic TLS

    Install Caddy:

    shell
    sudo apt -y install debian-keyring debian-archive-keyring apt-transport-https curl
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt -y install caddy

    Replace /etc/caddy/Caddyfile with:

    shell
    notebook.example.com {
        reverse_proxy 127.0.0.1:8080
    }
    shell
    sudo systemctl reload caddy

    marimo uses WebSockets for its reactive updates. Caddy's reverse_proxy handles WebSocket upgrades automatically, so no extra configuration is needed. (If you use nginx instead, you must add the Upgrade and Connection headers to the location block, or live reload will fail.)

    Visit https://notebook.example.com. You will be redirected to a login page; enter the token from your .env file. To link directly, append it as a query parameter: https://notebook.example.com?access_token=YOUR_TOKEN.

    Step 6: Running an edit server (optional)

    If you want a private remote authoring environment rather than a read only app, change the command to marimo edit. Keep the token, and add an IP allowlist in Caddy so only you can reach it. An edit server runs arbitrary code, so this is not optional hardening.

    In the Compose file:

    shell
        command: >
          marimo edit
          --host 0.0.0.0 -p 8080
          --token --token-password=${MARIMO_TOKEN}

    In the Caddyfile, restrict by source IP:

    shell
    notebook.example.com {
        @blocked not remote_ip 203.0.113.10
        respond @blocked "Forbidden" 403
        reverse_proxy 127.0.0.1:8080
    }

    Replace 203.0.113.10 with your own address. A simpler and stronger alternative for a personal edit server is to skip the public exposure entirely and use SSH port forwarding: run marimo edit --headless on the VPS and forward the port over SSH from your workstation, so the editor is never reachable from the internet at all.

    Step 7: Backups

    Notebooks are pure Python files, so a marimo backup is mostly a matter of preserving app.py, requirements.txt, and the Dockerfile. The cleanest approach is to keep the project directory in a Git repository and push it to a private remote, which gives you version history for free.

    For a local snapshot as well, create /usr/local/bin/marimo-backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M%S)
    DEST="/var/backups/marimo"
    mkdir -p "$DEST"
    tar czf "$DEST/marimo-$STAMP.tar.gz" \
      -C /var/lib/marimo app.py requirements.txt Dockerfile docker-compose.yml
    ls -1t "$DEST"/marimo-*.tar.gz | tail -n +15 | xargs -r rm
    shell
    sudo chmod +x /usr/local/bin/marimo-backup.sh
    echo "30 3 * * * deploy /usr/local/bin/marimo-backup.sh" | sudo tee /etc/cron.d/marimo-backup

    If your edit server lets users create new notebooks on the VPS, mount a host directory into the container as a volume and back that directory up as well, since those files will not be in your image or Git repo.

    Step 8: Updating

    When marimo or your dependencies change, bump requirements.txt and rebuild:

    shell
    cd /var/lib/marimo
    docker compose up -d --build

    Pin marimo to a specific version in requirements.txt (for example marimo==0.x.y) if you want reproducible rebuilds rather than tracking the latest release.

    Troubleshooting

    • Cannot reach the notebook, redirected to a login page repeatedly: you are entering the wrong token, or the MARIMO_TOKEN env var did not reach the container. Check with docker compose exec marimo env | grep MARIMO.
    • Live updates do not work, the app loads but never refreshes: the reverse proxy is not passing WebSocket upgrades. With Caddy this works out of the box; with nginx, add the upgrade headers.
    • Build fails on a dependency: the package needs a system library not in the slim base image. Add an apt-get install line for the missing build dependency, or switch to a non-slim Python base.
    • Health check failing: confirm the app actually binds to 0.0.0.0:8080 inside the container and that the /health endpoint responds with docker compose exec marimo python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8080/health').status)".