Run the full Observal stack on a single VM with Docker Compose. Best for teams of up to ~50 users, internal tools, evaluations, and any deployment where simplicity matters more than multi-AZ redundancy.
End state: Observal running behind TLS on one server, with automated backups to S3-compatible storage, surviving reboots, and upgradable in under a minute.
| Single-node (this guide) | Production Terraform | |
|---|---|---|
| Best for | Small/mid teams, internal use, POCs | Enterprise, SLA-bound, high-traffic |
| Infra | 1 VM, any cloud or on-prem | ~100 managed AWS resources |
| Cost | $20–150/mo | ~$255/mo |
| HA | No — single point of failure | Yes — Multi-AZ Postgres, autoscaling ECS |
| Time to deploy | 10 minutes | 20–30 minutes |
| Operational complexity | Low — SSH, docker compose, cron | Medium — Terraform, AWS console, CloudWatch |
| Scaling | Vertical (bigger VM) | Horizontal (more Fargate tasks) |
flowchart TB
subgraph vm[Single VM]
nginx["nginx - TLS"]
web[Web UI]
api["API - FastAPI"]
worker["Worker - arq"]
pg[(Postgres)]
redis[(Redis)]
ch[(ClickHouse)]
grafana[Grafana]
systemd["systemd - docker compose restart"]
cron["cron - daily backups to S3"]
end
nginx --> web
nginx --> api
api --> pg
api --> ch
api --> redis
api --> worker
worker --> pg
worker --> redis
worker --> ch
grafana --> ch
systemd -.-> nginx
systemd -.-> api
systemd -.-> worker
cron -.-> pg
cron -.-> ch
Everything runs as Docker containers on a single host. The nginx LB routes traffic and terminates TLS. Docker's restart policy and systemd keep the stack running across reboots.
| Requirement | Minimum | Recommended |
|---|---|---|
| VM | 2 vCPU, 4 GB RAM, 40 GB SSD | 4 vCPU, 8 GB RAM, 100 GB SSD |
| OS | Ubuntu 22.04+ / Amazon Linux 2023 / Debian 12 | Ubuntu 24.04 LTS |
| Docker | Engine ≥ 24.0 with Compose v2 | Latest stable |
| Domain (for TLS) | A DNS record pointing to the VM's public IP | — |
| Firewall | Ports 80, 443 open inbound | — |
| Team size | VM spec | Estimated cost (AWS) |
|---|---|---|
| 1–10 users | t3.medium (2 vCPU / 4 GB) |
~$30/mo |
| 10–30 users | t3.large (2 vCPU / 8 GB) |
~$60/mo |
| 30–50 users | t3.xlarge (4 vCPU / 16 GB) |
~$120/mo |
| 50+ users | Consider the Terraform module | ~$255/mo |
ClickHouse is the memory consumer. If you run out, increase CLICKHOUSE_MEMORY_LIMIT before resizing the VM.
Use any cloud provider or on-prem hypervisor. Example for AWS:
aws ec2 run-instances \
--image-id ami-0c7217cdde317cfec \
--instance-type t3.large \
--key-name your-key \
--security-group-ids sg-xxxxx \
--block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":100,"VolumeType":"gp3"}}]' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=observal}]'For other clouds:
- GCP:
e2-standard-2with 100 GB balanced persistent disk - Azure:
Standard_B2mswith 100 GB Premium SSD - Hetzner:
CPX31(4 vCPU / 8 GB, ~€15/mo) - On-prem: any Linux box meeting the specs above
SSH into the VM and install Docker:
ssh ubuntu@your-server-ip
# Ubuntu / Debian
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker version
docker compose versiongit clone https://github.com/Observal/Observal.git
cd Observal
cp .env.example .envEdit .env for production. At minimum, change these:
# Generate a secure secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# Set strong database passwords
POSTGRES_PASSWORD=$(openssl rand -base64 24)
CLICKHOUSE_PASSWORD=$(openssl rand -base64 24)
# Set your domain (used for CORS and OAuth redirects)
CORS_ALLOWED_ORIGINS=https://observal.yourcompany.com
FRONTEND_URL=https://observal.yourcompany.com
# Remove demo accounts for production
# Comment out or delete all DEMO_* variablesWrite them into .env:
sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$SECRET_KEY|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$POSTGRES_PASSWORD|" .env
sed -i "s|^CLICKHOUSE_PASSWORD=.*|CLICKHOUSE_PASSWORD=$CLICKHOUSE_PASSWORD|" .envEnterprise edition: Set
OBSERVAL_LICENSE_KEY=eyJ...in.envto enable SAML SSO, audit logs, and executive dashboards. See Configuration.
sudo apt install -y caddyCreate /etc/caddy/Caddyfile:
observal.yourcompany.com {
reverse_proxy localhost:80
}sudo systemctl enable --now caddyCaddy handles Let's Encrypt certificates automatically. No renewal cron needed.
sudo apt install -y certbot
sudo certbot certonly --standalone -d observal.yourcompany.comThen use the production compose overlay which configures nginx for TLS:
cd docker
docker compose -f docker-compose.yml -f docker-compose.production.yml up -dSet up auto-renewal:
echo "0 3 * * * certbot renew --quiet && docker compose -f /home/ubuntu/Observal/docker/docker-compose.yml -f /home/ubuntu/Observal/docker/docker-compose.production.yml restart observal-lb" | sudo tee -a /etc/crontabIf your VM sits behind an AWS ALB, GCP HTTPS LB, or Cloudflare, terminate TLS there and proxy to port 80 on the VM. No TLS config on the VM itself.
cd ~/Observal
docker compose -f docker/docker-compose.yml up -d --buildFirst build takes 3–5 minutes (pulling images, building the API and web containers). Watch the logs:
docker compose -f docker/docker-compose.yml logs -f observal-init observal-apiWait for:
observal-init | INFO - Database up to date.
observal-api | INFO - Application startup complete.
Verify:
curl -fsSL http://localhost/health
# {"status":"ok","initialized":true}Docker's restart: unless-stopped policy (already set in docker-compose.yml) handles container restarts. To ensure Docker itself starts on boot:
sudo systemctl enable dockerTest it:
sudo reboot
# After reconnecting:
docker compose -f ~/Observal/docker/docker-compose.yml ps
# All services should be runningCreate a backup script:
sudo mkdir -p /opt/observal-backups
cat << 'SCRIPT' | sudo tee /opt/observal-backups/backup.sh
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/observal-backups"
DATE=$(date +%Y%m%d-%H%M)
COMPOSE="docker compose -f /home/ubuntu/Observal/docker/docker-compose.yml"
# Postgres
$COMPOSE exec -T observal-db pg_dump -U postgres observal | gzip > "$BACKUP_DIR/pg-$DATE.sql.gz"
# JWT keys
$COMPOSE exec -T observal-api tar czf - -C /data keys > "$BACKUP_DIR/keys-$DATE.tar.gz"
# Prune backups older than 30 days
find "$BACKUP_DIR" -name "pg-*.sql.gz" -mtime +30 -delete
find "$BACKUP_DIR" -name "keys-*.tar.gz" -mtime +30 -delete
# Upload to S3 (optional — install awscli first)
# aws s3 sync "$BACKUP_DIR" s3://your-backup-bucket/observal/ --exclude "backup.sh"
echo "Backup completed: $DATE"
SCRIPT
sudo chmod +x /opt/observal-backups/backup.shSchedule it:
echo "0 3 * * * root /opt/observal-backups/backup.sh >> /var/log/observal-backup.log 2>&1" | sudo tee /etc/cron.d/observal-backupFor ClickHouse (weekly, since it's larger):
echo "0 4 * * 0 root docker compose -f /home/ubuntu/Observal/docker/docker-compose.yml exec -T observal-clickhouse clickhouse-client --password \$CLICKHOUSE_PASSWORD --query \"BACKUP DATABASE observal TO Disk('backups', 'weekly-\$(date +\%Y\%m\%d).zip')\" >> /var/log/observal-backup.log 2>&1" | sudo tee /etc/cron.d/observal-ch-backupSee Backup and restore for detailed restore procedures.
Install the CLI on your local machine:
curl -fsSL https://raw.githubusercontent.com/Observal/Observal/main/install.sh | bashLog in:
observal auth login
# Server URL: https://observal.yourcompany.com
# Email: (create your admin account or use demo creds if you kept them)Verify:
observal auth whoami
observal auth statuscd ~/Observal
git fetch --tags
git checkout v1.5.0 # or whatever version
docker compose -f docker/docker-compose.yml up -d --buildMigrations run automatically on API startup. See Upgrades for rollback procedures.
Or use the CLI:
observal server upgrade --version 1.5.0Prometheus and Grafana are optional. Start the core stack with Prometheus only:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.observability.yml up -dStart Prometheus and Grafana:
COMPOSE_PROFILES=grafana docker compose -f docker/docker-compose.yml -f docker/docker-compose.observability.yml up -dAccess Grafana at http://your-server:3001 when the Grafana profile is enabled.
For basic alerting without Grafana, add a health check cron:
echo "*/5 * * * * root curl -fsS http://localhost/health > /dev/null || echo 'Observal health check failed' | mail -s 'ALERT: Observal down' ops@yourcompany.com" | sudo tee /etc/cron.d/observal-healthBefore exposing to the internet:
- Remove all
DEMO_*env vars from.env - Set a strong
SECRET_KEY - Restrict SSH access (key-only, no password auth)
- Configure a firewall (
ufw allow 80,443/tcp && ufw enable) - Set up SSO if available
- Enable automatic security updates (
sudo apt install unattended-upgrades) - Bind database ports to localhost only (already done in
docker-compose.ymlvia127.0.0.1:prefix)
When you outgrow a single node:
| Symptom | Fix |
|---|---|
| API response times increasing | Increase API_WORKERS in .env (default 2), or bump to a bigger VM |
| ClickHouse queries slow | Increase CLICKHOUSE_MEMORY_LIMIT, move to a bigger VM, or externalize to ClickHouse Cloud |
| Disk filling up | Reduce DATA_RETENTION_DAYS, add a bigger disk, or move ClickHouse data to a separate volume |
| Need HA / zero downtime deploys | Migrate to the Terraform module |
- Configuration — all environment variables
- Backup and restore — detailed restore procedures
- Upgrades — safe upgrade and rollback flow
- Troubleshooting — common issues