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.
| Workload | vCPU | RAM | Disk |
|---|---|---|---|
| Light apps, small data | 1 | 1 to 2 GB | 25 GB |
| Pandas / numpy heavy notebooks | 2 | 4 GB | 40 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 arequirements.txtlisting its dependencies.
Step 1: Host hardening
Create a non-root sudo user:
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployConfirm key based login as deploy, then harden SSH in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication nosudo systemctl reload sshConfigure the firewall. marimo runs behind a reverse proxy, so only SSH, HTTP, and HTTPS are public:
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 enableEnable automatic security updates:
sudo apt -y install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgradesStep 2: Install Docker
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 deployLog out and back in for the group change.
Step 3: Build the app image
Set up the project:
sudo mkdir -p /var/lib/marimo
sudo chown -R deploy:deploy /var/lib/marimo
cd /var/lib/marimoPlace your app.py and requirements.txt here. The requirements.txt must list marimo plus any libraries your notebook imports, for example:
marimo
pandas
numpy
altairCreate /var/lib/marimo/Dockerfile. This uses uv for fast installs and runs as a non-root user inside the container:
# 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:
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: 3Generate a token and store it in /var/lib/marimo/.env:
echo "MARIMO_TOKEN=$(openssl rand -hex 24)" > /var/lib/marimo/.envBuild and start:
cd /var/lib/marimo
docker compose up -d --build
docker compose logs -fBinding 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:
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 caddyReplace /etc/caddy/Caddyfile with:
notebook.example.com {
reverse_proxy 127.0.0.1:8080
}sudo systemctl reload caddymarimo 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:
command: >
marimo edit
--host 0.0.0.0 -p 8080
--token --token-password=${MARIMO_TOKEN}In the Caddyfile, restrict by source IP:
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:
#!/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 rmsudo chmod +x /usr/local/bin/marimo-backup.sh
echo "30 3 * * * deploy /usr/local/bin/marimo-backup.sh" | sudo tee /etc/cron.d/marimo-backupIf 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:
cd /var/lib/marimo
docker compose up -d --buildPin 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_TOKENenv var did not reach the container. Check withdocker 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 installline 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:8080inside the container and that the/healthendpoint responds withdocker compose exec marimo python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8080/health').status)".
