Skip to content

Excalidraw whiteboard

Template

Excalidraw whiteboard

This pattern composes Compute and Network into a self-hosted collaborative whiteboard for a team, on infrastructure you control.

What this template does#

Provisions a single instance running Excalidraw, an open-source collaborative whiteboard (a self-hosted alternative to Figma or Miro for lightweight diagramming). Your team sketches diagrams together in real time on infrastructure you own:

  • Compute instance that runs the Excalidraw frontend and the excalidraw-room collaboration server in Docker, two lightweight, stateless containers with no bundled datastore (2 vCPU and 2 GiB RAM, the lightest ops-tools footprint in this library)
  • Private network, subnet, router, port, and security group; a floating IP for public access
  • No block volume: neither container persists drawing content server-side, so the boot disk is sufficient
  • cloud-init installs Docker Engine and writes the compose file, but does not start either container until you finish configuration

There is no credential to generate for this template: nothing here needs one.

The published image bakes in its own collaboration URL#

The excalidraw/excalidraw frontend is a Vite single-page app. VITE_APP_WS_SERVER_URL is inlined into the built JavaScript at image-build time, not read at container runtime, so the published image always points at Excalidraw's own public collaboration server (oss-collab.excalidraw.com) no matter what environment variable you set on the running container. Three independently authored self-hosting guides document the same workaround, cross-checked because this is a known rough edge in the official image: override the frontend container's entrypoint to sed-patch the hardcoded URL out of the built JavaScript assets at container start, using the real value of VITE_APP_WS_SERVER_URL, before starting Nginx. This template ships that patch as the container's entrypoint rather than a hidden workaround you would have to discover yourself.

Parameters#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist)No default
flavor_nameInstance size (both containers are lightweight and stateless, 2 vCPU / 2 GiB)s1a.small
image_nameOperating system imageUbuntu-24.04
app_nameDisplay name prefix for resourcesexcalidraw
external_networkExternal network for floating IP allocationPublicStatic
private_cidrCIDR for the private subnet10.58.0.0/24
app_allowed_cidrCIDR allowed to reach the frontend (8080) and collaboration server (8081)10.58.0.0/24

Finish setup after apply#

The frontend's collaboration patch depends on knowing the collaboration server's real public domain, and Excalidraw needs HTTPS (its end-to-end encryption uses window.crypto.subtle, which browsers only expose on a secure context), so cloud-init holds both containers until you finish configuration:

  1. Point two domains at the floating IP: one for the frontend (for example draw.example.com), one for the collaboration server (for example collab.example.com). These need to be two separate hostnames, each with its own HTTPS termination, because the frontend and the room server are reached at two different addresses.
  2. Put a reverse proxy in front of each: the frontend domain to port 8080, the collaboration domain to port 8081, with WebSocket upgrade enabled on the collaboration domain's proxy block.
  3. Edit /opt/excalidraw/.env: set VITE_APP_WS_SERVER_URL to your collaboration server's public HTTPS address.
  4. Start both containers:
bash
cd /opt/excalidraw
sudo docker compose up -d
  1. Open the frontend's HTTPS address and start a live collaboration session to confirm the patch applied.

Access and security#

Excalidraw's frontend listens on port 8080 and the collaboration server on port 8081, both over plain HTTP. The security group restricts both ports to app_allowed_cidr, which defaults to the private network only. The collaboration server has no built-in authentication: anyone with a live share link can join that room, which suits a small trusted team rather than a public-facing deployment.

When to use this pattern#

Run lightweight diagramming and whiteboarding for a team on a host you operate. There is nothing to back up server-side beyond the reverse-proxy configuration: drawing content lives in each browser's local storage, and the room server relays end-to-end-encrypted data without persisting it.

Estimated cost#

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on shared vCPU.

Starting template$13.10/mo

Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.

What each resource is for

Excalidraw whiteboard host

s1a.small · 2 shared vCPU, 2 GiB RAM, 0.5 Gbps

Runs the Excalidraw frontend and the excalidraw-room collaboration server as two stateless containers on the same instance, with no bundled datastore.

Both containers are lightweight and stateless, so 2 vCPU and 2 GiB RAM covers a small team; the lightest ops-tools footprint in this library alongside Uptime Kuma.

$16.50/mo

Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.

Included in baseline

s1a.small

2 shared vCPU, 2 GiB RAM, 0.5 Gbps

$16.50

Compute + RAM rate basis

2 vCPU + 2 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.

—

Block storage (20 GiB)

20 GiB at $0.08/GiB/mo

$1.60

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

Included at no charge

These line items are zero on Quake AI. Many other providers meter them separately.

Data transfer (inbound and outbound)

Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.

AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.

Learn more
$0.00

Private networking

Private networks, subnets, Neutron routers, and security groups are included with the plan.

VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.

$0.00

Control-plane API requests

OpenStack API calls for provisioning and management are included.

Some managed services on other clouds meter API calls or charge for premium control-plane features.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

7 files. Download the zip or expand to copy any file.Download excalidraw-whiteboard.zip
Show source (7 files)
main.tfHCL
data "openstack_images_image_v2" "os" {
  name        = var.image_name
  most_recent = true
}

data "openstack_networking_network_v2" "external" {
  name = var.external_network
}

resource "openstack_networking_network_v2" "private" {
  name           = "${var.app_name}-net"
  admin_state_up = true
}

resource "openstack_networking_subnet_v2" "private" {
  name            = "${var.app_name}-subnet"
  network_id      = openstack_networking_network_v2.private.id
  cidr            = var.private_cidr
  ip_version      = 4
  dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}

resource "openstack_networking_router_v2" "main" {
  name                = "${var.app_name}-router"
  external_network_id = data.openstack_networking_network_v2.external.id
}

resource "openstack_networking_router_interface_v2" "private" {
  router_id = openstack_networking_router_v2.main.id
  subnet_id = openstack_networking_subnet_v2.private.id
}

resource "openstack_networking_secgroup_v2" "excalidraw" {
  name        = "${var.app_name}-sg"
  description = "SSH and HTTP/HTTPS for a reverse proxy; frontend and collaboration ports restricted"
}

resource "openstack_networking_secgroup_rule_v2" "ssh" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_ip_prefix  = "0.0.0.0/0"
  security_group_id = openstack_networking_secgroup_v2.excalidraw.id
}

# 80 and 443 carry both Excalidraw hostnames once they are served through a
# reverse proxy (Caddy). Excalidraw needs HTTPS: the frontend calls
# window.crypto.subtle for its end-to-end encryption, which browsers only
# expose on a secure context.
resource "openstack_networking_secgroup_rule_v2" "http" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 80
  port_range_max    = 80
  remote_ip_prefix  = "0.0.0.0/0"
  security_group_id = openstack_networking_secgroup_v2.excalidraw.id
}

resource "openstack_networking_secgroup_rule_v2" "https" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 443
  port_range_max    = 443
  remote_ip_prefix  = "0.0.0.0/0"
  security_group_id = openstack_networking_secgroup_v2.excalidraw.id
}

# Raw frontend (8080) and collaboration-room (8081) ports are restricted to
# app_allowed_cidr (the private network by default). Use them for setup over
# an SSH tunnel or a scoped workstation IP; put a reverse proxy on 443 in
# front of each hostname for routine access.
resource "openstack_networking_secgroup_rule_v2" "app" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 8080
  port_range_max    = 8080
  remote_ip_prefix  = var.app_allowed_cidr
  security_group_id = openstack_networking_secgroup_v2.excalidraw.id
}

resource "openstack_networking_secgroup_rule_v2" "collab" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 8081
  port_range_max    = 8081
  remote_ip_prefix  = var.app_allowed_cidr
  security_group_id = openstack_networking_secgroup_v2.excalidraw.id
}

resource "openstack_networking_port_v2" "excalidraw" {
  name               = "${var.app_name}-port"
  network_id         = openstack_networking_network_v2.private.id
  security_group_ids = [openstack_networking_secgroup_v2.excalidraw.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.private.id
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_compute_instance_v2" "excalidraw" {
  name        = var.app_name
  flavor_name = var.flavor_name
  key_pair    = var.key_name

  user_data = templatefile("${path.module}/cloud-init/excalidraw.yaml.tftpl", {
    app_name = var.app_name
  })

  block_device {
    uuid                  = data.openstack_images_image_v2.os.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = 20
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.excalidraw.id
  }
}

resource "openstack_networking_floatingip_v2" "excalidraw" {
  pool = var.external_network
}

resource "openstack_networking_floatingip_associate_v2" "excalidraw" {
  floating_ip = openstack_networking_floatingip_v2.excalidraw.address
  port_id     = openstack_networking_port_v2.excalidraw.id
}
variables.tfHCL
variable "key_name" {
  description = "SSH keypair name (must already exist in your project)"
  type        = string
}

variable "flavor_name" {
  description = "Instance size. Excalidraw's frontend and collaboration-room server are two lightweight, stateless containers with no bundled datastore; 2 vCPU and 2 GiB RAM (s1a.small) is the lightest ops-tools footprint in this library."
  type        = string
  default     = "s1a.small"
}

variable "image_name" {
  description = "Operating system image. Ubuntu 24.04 is the recommended base."
  type        = string
  default     = "Ubuntu-24.04"
}

variable "app_name" {
  description = "Display name prefix for compute and network resources"
  type        = string
  default     = "excalidraw"
}

variable "external_network" {
  description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
  type        = string
  default     = "PublicStatic"
}

variable "private_cidr" {
  description = "CIDR for the private tenant network the instance lives in"
  type        = string
  default     = "10.58.0.0/24"
}

variable "app_allowed_cidr" {
  description = "CIDR allowed to reach the frontend (8080) and collaboration server (8081) directly. Defaults to the private network only, so neither port is exposed to the public internet directly. Put a reverse proxy on 443 in front of each hostname once you point domains at the instance. To reach the ports directly from your workstation during setup, set this to YOUR_IP/32."
  type        = string
  default     = "10.58.0.0/24"
}
outputs.tfHCL
output "instance_id" {
  description = "ID of the compute instance running Excalidraw"
  value       = openstack_compute_instance_v2.excalidraw.id
}

output "floating_ip" {
  description = "Public floating IP address of the Excalidraw host"
  value       = openstack_networking_floatingip_v2.excalidraw.address
}

output "private_ip" {
  description = "Private IP address of the instance"
  value       = openstack_compute_instance_v2.excalidraw.access_ip_v4
}

output "app_url" {
  description = "Excalidraw frontend URL on port 8080. Reachable from app_allowed_cidr (the private network by default). cloud-init does not start the containers; set VITE_APP_WS_SERVER_URL in /opt/excalidraw/.env and start both containers yourself."
  value       = "http://${openstack_networking_floatingip_v2.excalidraw.address}:8080"
}

output "collab_url" {
  description = "Excalidraw collaboration-room URL on port 8081. This is the raw port for setup only; once you point a public HTTPS domain at this port through a reverse proxy, that domain (not this URL) is the value to set VITE_APP_WS_SERVER_URL to."
  value       = "http://${openstack_networking_floatingip_v2.excalidraw.address}:8081"
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
  }
}

provider "openstack" {}
terraform.tfvars.exampleHCL
# Required: SSH keypair must already exist in your project
key_name = "YOUR_KEY_NAME"

# Recommended: restrict the frontend (8080) and collaboration (8081) ports to
# your workstation IP for setup. Leave unset to keep both reachable only from
# the private network and tunnel over SSH. For production use, put a reverse
# proxy in front on 443 for each hostname.
# app_allowed_cidr = "203.0.113.10/32"

# flavor_name = "s1a.small"
# image_name = "Ubuntu-24.04"
# app_name = "excalidraw"
# external_network = "PublicStatic"
# private_cidr = "10.58.0.0/24"
cloud-init/excalidraw.yaml.tftpl
#cloud-config
package_update: true
packages:
  - ca-certificates
  - curl
write_files:
  - path: /opt/excalidraw/docker-compose.yml
    permissions: "0644"
    content: |
      # Excalidraw whiteboard for ${app_name}. The frontend listens on
      # 8080, the collaboration server on 8081. The published frontend
      # image bakes https://oss-collab.excalidraw.com into its JavaScript
      # bundle at build time, so VITE_APP_WS_SERVER_URL only takes effect
      # through this entrypoint override, which patches the built assets
      # at container start before launching Nginx. No credential ships
      # with this template: there is nothing to generate. cloud-init does
      # not start either container: set VITE_APP_WS_SERVER_URL to your
      # collaboration server's own public HTTPS domain in
      # /opt/excalidraw/.env, then start both containers yourself.
      services:
        excalidraw:
          image: excalidraw/excalidraw:latest
          restart: unless-stopped
          ports:
            - "8080:80"
          env_file:
            - /opt/excalidraw/.env
          entrypoint: /bin/sh
          command:
            - -c
            - |
              echo "Patching hardcoded collab URL with: $$VITE_APP_WS_SERVER_URL"
              find /usr/share/nginx/html/assets -type f -name "*.js" \
                -exec sed -i "s|https://oss-collab\.excalidraw\.com|$$VITE_APP_WS_SERVER_URL|g" {} +
              echo "Starting nginx..."
              nginx -g 'daemon off;'
        excalidraw-room:
          image: excalidraw/excalidraw-room:latest
          restart: unless-stopped
          ports:
            - "8081:80"
runcmd:
  - |
    set -e
    # Install Docker Engine plus the compose plugin from Docker's
    # convenience script. No data volume: neither container persists
    # drawing content server-side, so the boot disk is sufficient.
    curl -fsSL https://get.docker.com | sh
    umask 077
    {
      echo "NODE_ENV=production"
      echo "# Set VITE_APP_WS_SERVER_URL to your collaboration server's"
      echo "# own public HTTPS domain (a second subdomain, distinct from"
      echo "# the frontend's domain) before starting the containers:"
      echo "# VITE_APP_WS_SERVER_URL=https://collab.example.com"
    } > /opt/excalidraw/.env
    chmod 600 /opt/excalidraw/.env
    # cloud-init does not start either container. The frontend's collab
    # patch depends on VITE_APP_WS_SERVER_URL pointing at a real domain,
    # so bring up the stack yourself once that is set:
    #   cd /opt/excalidraw && docker compose up -d
README.mdMarkdown
# Excalidraw whiteboard

Single compute instance running [Excalidraw](https://excalidraw.com), a self-hosted collaborative whiteboard (a self-hosted alternative to Figma or Miro for lightweight diagramming) on infrastructure you control. cloud-init prepares both containers; you set the collaboration server's public domain and start them yourself.


**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.

The instance provisions a private network and a floating IP. There is no block volume: neither container persists drawing content server-side, so the boot disk is sufficient.

## Where this fits

Excalidraw gives a team a fast, hand-drawn-style whiteboard for diagrams and quick sketches, with real-time multiplayer collaboration, on infrastructure you own rather than on a third-party SaaS.

## The published image bakes in its own collaboration URL

The `excalidraw/excalidraw` frontend is a Vite single-page app. `VITE_APP_WS_SERVER_URL` is inlined into the built JavaScript at image-build time, not read at container runtime, so the published image always points at Excalidraw's own public collaboration server (`oss-collab.excalidraw.com`) no matter what environment variable you set on the running container. Three independently authored self-hosting guides document the same workaround, cross-checked because this is a known rough edge in the official image: override the frontend container's entrypoint to `sed`-patch the hardcoded URL out of the built JavaScript assets at container start, using the real value of `VITE_APP_WS_SERVER_URL`, before starting Nginx. This template ships that patch as the container's entrypoint rather than a hidden workaround you would have to discover yourself.

## Resource floor: the lightest ops-tools template in this library

Both containers are lightweight and stateless. This template's `s1a.small` default (2 vCPU, 2 GiB RAM) covers a small team; there is no bundled database to size for.

## Prerequisites

- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- An existing SSH keypair in your project (the value of `key_name` must match that keypair)

## Usage

1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`

After apply, cloud-init installs Docker and writes `/opt/excalidraw/docker-compose.yml` and `/opt/excalidraw/.env`, but does not start either container. There is no credential to generate for this template: nothing here needs one.

## Finish setup after apply

The frontend's collaboration patch depends on knowing the collaboration server's real public domain, and Excalidraw needs HTTPS (its end-to-end encryption uses `window.crypto.subtle`, which browsers only expose on a secure context), so cloud-init holds both containers until you finish configuration:

1. Point two domains at `floating_ip`: one for the frontend (for example `draw.example.com`), one for the collaboration server (for example `collab.example.com`). These need to be two separate hostnames, each with its own HTTPS termination, because the frontend and the room server are reached at two different addresses.
2. Put a reverse proxy (Caddy or Nginx) in front of each: `draw.example.com` to port 8080, `collab.example.com` to port 8081. The collaboration hostname's proxy block needs WebSocket upgrade enabled; Caddy does this automatically for `reverse_proxy`.
3. Edit `/opt/excalidraw/.env`: set `VITE_APP_WS_SERVER_URL` to your collaboration server's public HTTPS address (for example `https://collab.example.com`).
4. Start both containers:

```bash
cd /opt/excalidraw
sudo docker compose up -d
```

5. Open the frontend's HTTPS address and start a live collaboration session to confirm the patch applied: in the browser's network tab, the WebSocket connection should go to your own collaboration domain, not `oss-collab.excalidraw.com`.

## Access and security

Excalidraw's frontend listens on port 8080 and the collaboration server on port 8081, both over plain HTTP. The security group restricts both ports to `app_allowed_cidr`, which defaults to the private network only. Ports 80 and 443 stay open for the reverse proxies you add for production use; they carry no traffic until you add them. The collaboration server has no built-in authentication: anyone with a live share link can join that room, which suits a small trusted team rather than a public-facing deployment.

## Datastores

None. Excalidraw's collaboration protocol is end-to-end encrypted and stateless server-side: the room server relays encrypted data between clients without persisting drawings. Drawing content lives in each browser's local storage.

## Variables

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | SSH keypair name (must already exist in your project) |
| `flavor_name` | string | no | `s1a.small` | Instance size (both containers are lightweight and stateless, 2 vCPU / 2 GiB) |
| `image_name` | string | no | `Ubuntu-24.04` | Operating system image |
| `app_name` | string | no | `excalidraw` | Display name prefix for resources |
| `external_network` | string | no | `PublicStatic` | Persisted FIP / production default; override with `PublicEphemeral` for demos |
| `private_cidr` | string | no | `10.58.0.0/24` | CIDR for the private subnet |
| `app_allowed_cidr` | string | no | `10.58.0.0/24` | CIDR allowed to reach the frontend (8080) and collaboration server (8081) |

## Outputs

| Name | Description |
| --- | --- |
| `floating_ip` | Public floating IP assigned to the instance |
| `private_ip` | Private IP address of the instance |
| `app_url` | Excalidraw frontend URL on port 8080 |
| `collab_url` | Excalidraw collaboration-room URL on port 8081 |
| `instance_id` | Compute instance ID |

## Scope

This is a single-VM Excalidraw host that you operate, not a managed multi-tenant whiteboard service. It is CPU-only, runs in one region, and bundles no database because there is nothing to persist server-side. You operate the instance, Docker, and both containers yourself: patch them and reapply the collaboration-URL fix on every image update (the entrypoint override reapplies it automatically on every container start).

## Documentation

See also: [Mattermost team chat](/resources/iac-templates/mattermost-team-chat), [Nextcloud files and collaboration](/resources/iac-templates/nextcloud-files), [Infisical secrets management](/resources/iac-templates/infisical-secrets)
Resources, parameters, and variables
Provisions
Parameterized by
Variables
  • key_namerequired
  • flavor_name="s1a.small"
  • image_name="Ubuntu-24.04"
  • app_name="excalidraw"
  • external_network="PublicStatic"
  • private_cidr="10.58.0.0/24"
  • app_allowed_cidr="10.58.0.0/24"

Customize this pattern#

See also#

Usage Guidelines

The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.

For the full policy, see Usage Guidelines.

Was this page helpful?