rsrc/extra/docs/webvm.md
2026-10-20 10:52:06 +00:00

7.8 KiB

webvm for self host


install script:

  • install required packages
  • create /opt/webvm-aio
  • ask for:
    • domain
    • Let's Encrypt email
    • image filename
  • write:
    • .env
    • docker-compose.caddy.yml
    • docker-compose.webvm.yml
    • Caddyfile
    • nginx.conf
    • bootstrap.sh
    • deploy.sh
  • create the shared Docker network web
  • start Caddy
  • tell you where to upload your .ext2 image
  • optionally bootstrap immediately if the image is already present

copy-paste

debian .ext2

alpine .ext2

run as sudo-capable user:

bash <(cat <<'EOF'
set -euo pipefail

ROOT_DIR="/opt/webvm-aio"

echo "==> WebVM AIO installer"

if ! command -v sudo >/dev/null 2>&1; then
  echo "sudo is required"
  exit 1
fi

read -rp "Domain (e.g. webvm.example.com): " WEBVM_DOMAIN
read -rp "Let's Encrypt email: " LETSENCRYPT_EMAIL
read -rp "Disk image filename (e.g. debian_mini_20230519_5022088024.ext2): " WEBVM_IMAGE

if [ -z "${WEBVM_DOMAIN}" ] || [ -z "${LETSENCRYPT_EMAIL}" ] || [ -z "${WEBVM_IMAGE}" ]; then
  echo "All fields are required."
  exit 1
fi

echo "==> Installing packages"
sudo apt update
sudo apt install -y git curl docker.io docker-compose-plugin nodejs npm python3

echo "==> Enabling docker"
sudo systemctl enable --now docker

echo "==> Creating layout at $ROOT_DIR"
sudo mkdir -p "$ROOT_DIR"
sudo chown -R "$USER":"$USER" "$ROOT_DIR"

mkdir -p "$ROOT_DIR"/{images,index,src}
mkdir -p "$ROOT_DIR/index/custom-disk-images"

cat > "$ROOT_DIR/.env" <<ENV
WEBVM_DOMAIN=$WEBVM_DOMAIN
WEBVM_IMAGE=$WEBVM_IMAGE
WEBVM_ROOT=$ROOT_DIR
LETSENCRYPT_EMAIL=$LETSENCRYPT_EMAIL
ENV

cat > "$ROOT_DIR/docker-compose.caddy.yml" <<'YAML'
version: "3.9"

services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:2.9-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      - CADDY_INGRESS_NETWORKS=web
    networks:
      - web
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

networks:
  web:
    external: true

volumes:
  caddy_data:
  caddy_config:
YAML

cat > "$ROOT_DIR/docker-compose.webvm.yml" <<'YAML'
version: "3.9"

services:
  webvm:
    image: nginx:alpine
    container_name: webvm
    restart: unless-stopped
    networks:
      - web
    volumes:
      - ./index:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    labels:
      caddy: ${WEBVM_DOMAIN}
      caddy.reverse_proxy: "{{upstreams 80}}"
      caddy.encode: zstd gzip

networks:
  web:
    external: true
YAML

cat > "$ROOT_DIR/Caddyfile" <<'CADDY'
{
	email {$LETSENCRYPT_EMAIL}
}
CADDY

cat > "$ROOT_DIR/nginx.conf" <<'NGINX'
server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    types {
        application/wasm wasm;
    }

    add_header Cross-Origin-Opener-Policy same-origin always;
    add_header Cross-Origin-Embedder-Policy require-corp always;
    add_header Cross-Origin-Resource-Policy same-origin always;
    add_header X-Content-Type-Options nosniff always;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /custom-disk-images/ {
        autoindex off;
        add_header Cache-Control "public, max-age=3600";
        try_files $uri =404;
    }

    location ~* \.(wasm|bin|img|ext2)$ {
        add_header Cache-Control "public, max-age=3600";
        try_files $uri =404;
    }

    location ~* \.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    client_max_body_size 50m;
}
NGINX

cat > "$ROOT_DIR/bootstrap.sh" <<'BOOT'
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="${WEBVM_ROOT:-/opt/webvm-aio}"
if [ -f "$ROOT_DIR/.env" ]; then
  set -a
  . "$ROOT_DIR/.env"
  set +a
fi

SRC_DIR="$ROOT_DIR/src/webvm"
PUB_DIR="$ROOT_DIR/index"
IMG_SRC_DIR="$ROOT_DIR/images"
IMG_NAME="${WEBVM_IMAGE:-debian_mini_20230519_5022088024.ext2}"
IMG_SRC="$IMG_SRC_DIR/$IMG_NAME"
IMG_PUB_DIR="$PUB_DIR/custom-disk-images"
IMG_PUB="$IMG_PUB_DIR/$IMG_NAME"

echo "==> root: $ROOT_DIR"
echo "==> source: $SRC_DIR"
echo "==> publish: $PUB_DIR"
echo "==> image source: $IMG_SRC"

mkdir -p "$ROOT_DIR" "$ROOT_DIR/src" "$PUB_DIR" "$IMG_SRC_DIR" "$IMG_PUB_DIR"

if ! command -v docker >/dev/null 2>&1; then
  echo "ERROR: docker is not installed"
  exit 1
fi

if ! docker network inspect web >/dev/null 2>&1; then
  echo "==> creating shared docker network: web"
  docker network create web
fi

echo "==> starting caddy"
cd "$ROOT_DIR"
docker compose --env-file .env -f docker-compose.caddy.yml up -d

if [ ! -f "$IMG_SRC" ]; then
  echo
  echo "ERROR: missing disk image:"
  echo "  $IMG_SRC"
  echo
  echo "Upload your .ext2 image there, then rerun:"
  echo "  $ROOT_DIR/bootstrap.sh"
  echo
  exit 1
fi

if [ ! -d "$SRC_DIR/.git" ]; then
  echo "==> cloning webvm"
  git clone https://github.com/leaningtech/webvm.git "$SRC_DIR"
else
  echo "==> updating webvm"
  git -C "$SRC_DIR" pull --ff-only
fi

cd "$SRC_DIR"

echo "==> installing dependencies"
npm install

CONFIG_FILE="$SRC_DIR/config_public_terminal.js"

echo "==> patching WebVM config"
python3 - <<PY
from pathlib import Path
import re

p = Path("$CONFIG_FILE")
s = p.read_text()

s = re.sub(
    r'export const diskImageUrl\\s*=\\s*".*?";',
    'export const diskImageUrl = "/custom-disk-images/$IMG_NAME";',
    s
)
s = re.sub(
    r'export const diskImageType\\s*=\\s*".*?";',
    'export const diskImageType = "bytes";',
    s
)

p.write_text(s)
print("patched", p)
PY

echo "==> building webvm"
npm install
npm run build

echo "==> publishing build"
rm -rf "$PUB_DIR"/*
mkdir -p "$IMG_PUB_DIR"

if [ -d "$SRC_DIR/dist" ]; then
  cp -r "$SRC_DIR/dist/"* "$PUB_DIR"/
else
  echo "ERROR: expected build output in $SRC_DIR/dist"
  exit 1
fi

cp "$IMG_SRC" "$IMG_PUB"

echo "==> starting webvm"
cd "$ROOT_DIR"
docker compose --env-file .env -f docker-compose.webvm.yml up -d

echo
echo "Done."
echo "Site: https://${WEBVM_DOMAIN}"
echo "Image: https://${WEBVM_DOMAIN}/custom-disk-images/$IMG_NAME"
BOOT

cat > "$ROOT_DIR/deploy.sh" <<'DEPLOY'
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/opt/webvm-aio"
cd "$ROOT_DIR"
./bootstrap.sh
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
DEPLOY

chmod +x "$ROOT_DIR/bootstrap.sh" "$ROOT_DIR/deploy.sh"

echo "==> Ensuring docker network exists"
docker network inspect web >/dev/null 2>&1 || docker network create web

echo "==> Starting Caddy"
cd "$ROOT_DIR"
docker compose --env-file .env -f docker-compose.caddy.yml up -d

echo
echo "==> Install complete"
echo
echo "Next steps:"
echo "1. Point DNS for $WEBVM_DOMAIN to this server."
echo "2. Upload your disk image to:"
echo "   $ROOT_DIR/images/$WEBVM_IMAGE"
echo
echo "Example upload:"
echo "   scp $WEBVM_IMAGE $USER@$(hostname -I | awk '{print $1}'):$ROOT_DIR/images/"
echo
read -rp "If the image is already present, bootstrap now? [y/N]: " DO_BOOTSTRAP
if [[ "${DO_BOOTSTRAP:-}" =~ ^[Yy]$ ]]; then
  "$ROOT_DIR/bootstrap.sh"
else
  echo
  echo "When ready, run:"
  echo "  cd $ROOT_DIR && ./bootstrap.sh"
fi
EOF
)

After running it

1. Point DNS

Set A record for your domain to your VPS IP.

2. Upload the .ext2 image

Example:

scp debian_mini_20230519_5022088024.ext2 user@your-server:/opt/webvm-aio/images/

3. Bootstrap

cd /opt/webvm-aio
./bootstrap.sh

4. Open

https://your-domain

Notes

If ports 80/443 are already used

Stop any existing web server before running Caddy:

sudo systemctl stop nginx
sudo systemctl disable nginx

or stop any existing container using 80/443.