Apache Superset Docker Deployment

Superset is a powerful, open-source data visualization and business intelligence platform maintained by the Apache Foundation. It’s ideal for teams who want modern dashboards without handing data over to third parties. After experimenting with tools like Metabase, I landed on Superset for its extensibility, better dashboard customisation, and alerting/reporting features.

I struggled finding a comprehensive guide for Docker-based deployments beyond the basic setup, so I'll walk through my deployment process for Superset, from setting up the host and containers, to branding and email alerts.

Why Superset?

Before diving into deployment, a quick comparison.

Feature | Superset | Metabase
Data engine support | Wide (SQLAlchemy-compatible) | Limited but growing
Chart/dash flexibility | Advanced (custom CSS, D3, JS plugins) | Simple and opinionated
Alerts & reports | Built-in with scheduling (via Celery) | Available on paid plans
Embedding options | Fine-grained (auth, iframe, SSO) | Basic iframe embedding
Community | Apache-backed, active dev scene | Also solid, with a user-friendly vibe

Metabase is simpler and easier to get started with, especially for business users. But if you're comfortable with SQL and want granular control, Superset offers a lot more power.

Deploying Superset with Docker Compose

I’m running this on a remote Linux server via Docker. We’ll use a persistent folder for Superset configs and database volumes.

Step 1: Prepare the Host

SSH into your server and run:

sudo mkdir -p /opt/superset/{home,db}
sudo chown -R 1000:1000 /opt/superset

This creates persistent storage for Superset's configuration and the PostgreSQL database.

Step 2: Docker Compose Setup (Full Version with Commands)

The complete Compose stack includes Superset, Postgres, Redis, a Celery worker for async tasks, and a beat scheduler for scheduled reports.

version: "3.8"

services:
superset:
image: apache/superset:latest
container_name: superset
ports:
- "8088:8088"
environment:
- SUPERSET_ENV=production
- SUPERSET_LOAD_EXAMPLES=no
- SUPERSET_SECRET_KEY=your-secret-key
- SUPERSET_REDIS_HOST=redis
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
depends_on:
- db
- redis
restart: always
networks:
- shared_network
command: >
/bin/bash -c "
pip install --no-cache-dir --no-warn-script-location psycopg2-binary prophet gevent openpyxl pandas-gbq pymysql elasticsearch-dbapi snowflake-connector-python cryptography flask-mail &&
export FLASK_APP=superset &&
superset db upgrade &&
if ! superset fab list-users | grep -q admin; then
superset fab create-admin --username admin --firstname Superset --lastname Admin --email admin@example.com --password 'YourSecurePassword';
fi &&
superset init &&
gunicorn --workers 4 --worker-class gthread --timeout 120 -b 0.0.0.0:8088 'superset.app:create_app()'
"

db:
image: postgres:13
container_name: superset_db
environment:
- POSTGRES_DB=superset
- POSTGRES_USER=superset
- POSTGRES_PASSWORD=superset
ports:
- "6125:5432"
volumes:
- /opt/superset/db:/var/lib/postgresql/data
restart: always
networks:
- shared_network

redis:
image: redis:latest
container_name: superset_redis
restart: always
networks:
- shared_network

worker:
image: apache/superset:latest-dev
container_name: superset_worker
command: celery --app=superset.tasks.celery_app:app worker --pool=gevent --concurrency=4
environment:
- SUPERSET_ENV=production
- SUPERSET_SECRET_KEY=your-secret-key
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=db+postgresql://superset:superset@db:5432/superset
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
depends_on:
- redis
- db
restart: always
networks:
- shared_network

beat:
image: apache/superset:latest-dev
container_name: superset_beat
command: >
/bin/bash -c "mkdir -p /app/superset_home && chmod -R a+rwX /app/superset_home && celery \
--app=superset.tasks.celery_app:app beat \
--pidfile= \
--schedule=/app/superset_home/celerybeat-schedule \
--loglevel=info"
environment:
- SUPERSET_ENV=production
- SUPERSET_SECRET_KEY=your-secret-key
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=db+postgresql://superset:superset@db:5432/superset
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
depends_on:
- redis
- db
restart: always
networks:
- shared_network

networks:
shared_network:
external: true

`docker-compose.yml

If running this in production, use secrets and do not embed the credentials in the compose file.

Custom Branding

Tweak your /opt/superset/home/superset_config.py to rebrand the UI:

APP_NAME = "My BI Platform" # Top-left title and browser tab title

# Where clicking the logo takes users (e.g., your dashboard homepage)
LOGO_TARGET_PATH = "https://yourdomain.com"

# Replace the Superset logo (top-left) with your own
APP_ICON = "https://yourdomain.com/static/logo.svg"

# Favicon (browser tab icon)
FAVICONS = [
{
"rel": "icon",
"type": "image/png",
"sizes": "32x32",
"href": "https://yourdomain.com/static/favicon-32x32.png",
},
{
"rel": "icon",
"type": "image/png",
"sizes": "16x16",
"href": "https://yourdomain.com/static/favicon-16x16.png",
},
]

Enabling Email Reports

Add this to the same config, this will enable the report/alert feature on Superset:

EMAIL_NOTIFICATIONS = True
SMTP_HOST = "smtp.yourprovider.com"
SMTP_PORT = 587
SMTP_STARTTLS = True
SMTP_SSL = False
SMTP_USER = "alerts@example.com"
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_MAIL_FROM = "alerts@example.com"

ALERT_REPORTS_SUPERSET_WEBDRIVER = {
"auth_type": "AUTH_FORM",
"auth_user": "admin",
"auth_password": os.getenv("SUPERSET_ADMIN_PW", ""),
"login_url": "https://yourdomain.com/login/",
}

What’s Next?

Hook up your databases

Build dashboards and slice charts

Embed dashboards internally or in apps

Define roles and access rules

Final Thoughts

Superset can be a bit heavy out the gate, but once it’s running, it becomes a versatile and professional-grade BI platform, all while keeping your data under your control.

UPDATE 11/08/2025

Since writing the post above, I’ve upgraded my Superset stack to do two things:

Real-time async queries via WebSockets
Charts render the moment they’re ready (no browser polling).

Headless screenshots & email reports without a custom Dockerfile
The Celery worker uses the latest-dev image which bundles a headless browser/driver.

What changed

WebSockets for GAQ (browser <-> WS <-> Redis):
Previously, the browser polled /api/v1/async_query/... every X ms. With WS, the server pushes “query finished” events the instant Celery writes status to Redis. Lower latency, fewer HTTP requests, faster dashboards.

Split images: stable web, -dev worker:
The main app stays on the small, stable image. Only the worker uses *-dev, which bundles headless Firefox + geckodriver. Reports/thumbnails work without baking a custom image; web container remains lean and safer.

Correct webdriver scoping:

WEBDRIVER_BASEURL points to http://superset:8088/ (Docker-internal) so Selenium can log in and carry cookies reliably.

WEBDRIVER_BASEURL_USER_FRIENDLY (and email links) use your external HTTPS URL.
Avoids cookie domain mismatches and "can’t login" screenshot failures.

Celery wired up:
imports=("superset.sql_lab", "superset.tasks",) ensures all Superset tasks (reports, thumbnails, cache-warmup) are registered. Beat has the same GAQ secret, so it can start without exploding.

Performance headroom:

Gunicorn on gevent with higher worker connections = better I/O concurrency.

Redis caches for results, form state, thumbnails.

docker-compose.yml

version: "3.8"

services:
superset:
image: apache/superset:latest
container_name: superset
ports: ["8088:8088"]
environment:
- SUPERSET_ENV=production
- SUPERSET_LOAD_EXAMPLES=no
- SUPERSET_REDIS_HOST=redis
- JWT_COOKIE_NAME=async-token
- CACHE_CONFIG={"CACHE_TYPE":"RedisCache","CACHE_DEFAULT_TIMEOUT":3600,"CACHE_KEY_PREFIX":"superset_","CACHE_REDIS_HOST":"redis","CACHE_REDIS_PORT":6379}
- DATA_CACHE_CONFIG={"CACHE_TYPE":"RedisCache","CACHE_DEFAULT_TIMEOUT":3600,"CACHE_KEY_PREFIX":"superset_","CACHE_REDIS_HOST":"redis","CACHE_REDIS_PORT":6379}
- RATELIMIT_ENABLED=false
- RATELIMIT_STORAGE_URL=redis://redis:6379
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- GLOBAL_ASYNC_QUERIES_JWT_SECRET=${GLOBAL_ASYNC_QUERIES_JWT_SECRET}
- ASYNC_QUERIES_JWT_ALGO=HS256
- SUPERSET_SECRET_KEY=${SUPERSET_SECRET_KEY}
# SMTP + webdriver creds used by “Test email” and thumbnails
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SUPERSET_ADMIN_USER=${SUPERSET_ADMIN_USER}
- SUPERSET_ADMIN_PW=${SUPERSET_ADMIN_PW}
# Browsers connect via public WSS
- GAQ_WS_URL=wss://superset.example.com/ws/
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
- /opt/superset/logo.png:/app/superset/static/assets/images/logo.png
- /opt/superset/logo.png:/app/superset/static/assets/images/favicon.png
restart: always
depends_on: [db, redis]
networks: [shared_network]
deploy:
resources:
limits:
cpus: "4.0"
memory: 6G
reservations:
cpus: "2.0"
memory: 2G
command: >
/bin/bash -c "
pip install --no-cache-dir --no-warn-script-location psycopg2-binary pillow prophet gevent openpyxl pandas-gbq pymysql elasticsearch-dbapi snowflake-connector-python cryptography flask-mail &&
export FLASK_APP=superset &&
superset db upgrade &&
if ! superset fab list-users | grep -q ${SUPERSET_ADMIN_USER}; then
superset fab create-admin --username ${SUPERSET_ADMIN_USER} --firstname Superset --lastname Admin --email admin@example.com --password '${SUPERSET_ADMIN_PW}';
fi &&
superset init &&
gunicorn --workers 4 --threads 6 --worker-class gthread --timeout 180 -b 0.0.0.0:8088 'superset.app:create_app()'
"

db:
image: postgres:13
container_name: superset_db
environment:
- POSTGRES_DB=superset
- POSTGRES_USER=superset
- POSTGRES_PASSWORD=superset
ports: ["6125:5432"]
volumes:
- /opt/superset/db:/var/lib/postgresql/data
restart: always
networks: [shared_network]

redis:
image: redis:latest
container_name: superset_redis
restart: always
networks: [shared_network]

worker:
image: apache/superset:latest-dev
container_name: superset_worker
working_dir: /app/superset_home
command: >
/bin/bash -c "
pip install --no-cache-dir pillow &&
celery --app=superset.tasks.celery_app:app worker --pool=gevent --concurrency=4
"
environment:
- SUPERSET_ENV=production
- SUPERSET_REDIS_HOST=redis
- SUPERSET_SECRET_KEY=${SUPERSET_SECRET_KEY}
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- GLOBAL_ASYNC_QUERIES_JWT_SECRET=${GLOBAL_ASYNC_QUERIES_JWT_SECRET}
- ASYNC_QUERIES_JWT_ALGO=HS256
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SUPERSET_ADMIN_USER=${SUPERSET_ADMIN_USER}
- SUPERSET_ADMIN_PW=${SUPERSET_ADMIN_PW}
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
depends_on: [redis, db, superset]
restart: always
networks: [shared_network]
deploy:
resources:
limits:
cpus: "3.0"
memory: 3G
reservations:
cpus: "1.0"
memory: 1G

beat:
image: apache/superset:latest
container_name: superset_beat
command: >
/bin/bash -c "mkdir -p /app/superset_home && chmod -R a+rwX /app/superset_home && celery
--app=superset.tasks.celery_app:app beat
--pidfile=
--schedule=/app/superset_home/celerybeat-schedule
--loglevel=info"
environment:
- SUPERSET_ENV=production
- SUPERSET_REDIS_HOST=redis
- SUPERSET_SECRET_KEY=${SUPERSET_SECRET_KEY}
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://superset:superset@db:5432/superset
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- GLOBAL_ASYNC_QUERIES_JWT_SECRET=${GLOBAL_ASYNC_QUERIES_JWT_SECRET}
- ASYNC_QUERIES_JWT_ALGO=HS256
- SMTP_PASSWORD=${SMTP_PASSWORD}
- GAQ_WS_URL=wss://superset.example.com/ws/
volumes:
- /opt/superset/home:/app/superset_home
- /opt/superset/home/superset_config.py:/app/pythonpath/superset_config.py
depends_on: [redis, db]
restart: always
networks: [shared_network]

websocket:
image: apache/superset:latest-websocket
container_name: superset_websocket
environment:
- JWT_SECRET=${GLOBAL_ASYNC_QUERIES_JWT_SECRET}
- JWT_COOKIE_NAME=async-token
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
depends_on: [redis]
restart: always
networks: [shared_network]

networks:
shared_network:
external: true

Put secrets in a .env next to the compose file:

superset_config.py

import os
from celery.schedules import crontab
from cachelib.redis import RedisCache

# ───────────────────────── Celery ─────────────────────────
class CeleryConfig:
broker_url = "redis://redis:6379/0"
result_backend = "redis://redis:6379/1"
imports = ("superset.sql_lab", "superset.tasks")
worker_prefetch_multiplier = 1
task_acks_late = True
task_soft_time_limit = 300
task_time_limit = 360
beat_schedule = {
"reports.scheduler": {
"task": "reports.scheduler",
"schedule": crontab(minute="*", hour="*"),
},
"reports.prune_log": {
"task": "reports.prune_log",
"schedule": crontab(minute=0, hour=0),
},
}

CELERY_CONFIG = CeleryConfig

# ───────────────────────── Branding ───────────────────────
APP_NAME = "Superset BI"
APP_ICON = "/static/assets/images/logo.png"
APP_ICON_WIDTH = 200
LOGO_TARGET_PATH = "https://superset.example.com"
FAVICON = "/static/assets/images/logo.png"

# ───────────────────────── Core security ──────────────────
SECRET_KEY = os.getenv("SUPERSET_SECRET_KEY")
WTF_CSRF_ENABLED = True
PREFERRED_URL_SCHEME = "https"

SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"

# ───────────────────────── Reverse proxy ──────────────────
ENABLE_PROXY_FIX = True
PROXY_FIX_CONFIG = {"x_for": 2, "x_proto": 2, "x_host": 2, "x_port": 2, "x_prefix": 1}

# ───────────────────────── Metadata DB ────────────────────
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://superset:superset@db:5432/superset"
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 25,
"max_overflow": 50,
"pool_pre_ping": True,
"pool_recycle": 300,
}

# ───────────────────────── Caching ────────────────────────
RESULTS_BACKEND = RedisCache(host="redis", port=6379, key_prefix="superset_results_")
CACHE_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_DEFAULT_TIMEOUT": 3600,
"CACHE_KEY_PREFIX": "superset_",
"CACHE_REDIS_HOST": "redis",
"CACHE_REDIS_PORT": 6379,
}
DATA_CACHE_CONFIG = dict(CACHE_CONFIG)

FILTER_STATE_CACHE_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_DEFAULT_TIMEOUT": 86400,
"CACHE_KEY_PREFIX": "filter_state_",
"CACHE_REDIS_HOST": "redis",
"CACHE_REDIS_PORT": 6379,
}

EXPLORE_FORM_DATA_CACHE_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_KEY_PREFIX": "explore_",
"CACHE_REDIS_HOST": "redis",
"CACHE_REDIS_PORT": 6379,
"CACHE_DEFAULT_TIMEOUT": 300,
}
THUMBNAIL_CACHE_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_KEY_PREFIX": "thumb_",
"CACHE_REDIS_HOST": "redis",
"CACHE_REDIS_PORT": 6379,
"CACHE_DEFAULT_TIMEOUT": 3600,
}

# ───────────────────────── Rate limiting ──────────────────
RATELIMIT_ENABLED = False
RATELIMIT_STORAGE_URI = "redis://redis:6379/3"

# ───────────────────────── Timeouts / limits ──────────────
SUPERSET_WEBSERVER_TIMEOUT = 180
ENABLE_TIME_ROTATE = True
ROW_LIMIT = 10000

SQLLAB_TIMEOUT = 600
SQLLAB_ASYNC_TIME_LIMIT_SEC = 600
SQLLAB_CTAS_NO_LIMIT = True

# ───────────────────────── Email / Alerts & Reports ───────
EMAIL_NOTIFICATIONS = True
SMTP_HOST = "smtp.office365.com"
SMTP_PORT = 587
SMTP_STARTTLS = True
SMTP_SSL = False
SMTP_USER = "reports@example.com"
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_MAIL_FROM = "reports@example.com"

WEBDRIVER_BASEURL = "https://superset.example.com/"
WEBDRIVER_BASEURL_USER_FRIENDLY = "https://superset.example.com/"

EMAIL_REPORTS_CTA = "Explore in Superset BI"
EMAIL_REPORTS_SUBJECT_PREFIX = "[BI REPORT] "
ALERT_REPORTS_NOTIFICATION_DRY_RUN = False

ALERT_REPORTS_SUPERSET_WEBDRIVER = {
"auth_type": "AUTH_FORM",
"auth_user": os.getenv("SUPERSET_ADMIN_USER", "admin"),
"auth_password": os.getenv("SUPERSET_ADMIN_PW", ""),
"login_url": "https://superset.example.com/login/",
}

# ───────────────────────── Feature flags ───────────────────
FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"EMBEDDED_SUPERSET": True,
"PLAYWRIGHT_REPORTS_AND_THUMBNAILS": False,
"ALLOW_FULL_CSV_EXPORT": True,
"DASHBOARD_VIRTUALIZATION": False,
"DASHBOARD_LAZY_RENDERING": False,
"DASHBOARD_NATIVE_FILTERS": True,
"DASHBOARD_NATIVE_FILTERS_SET": True,
"SHARE_QUERIES_VIA_KV_STORE": True,
"EMBEDDABLE_CHARTS": True,
"GLOBAL_ASYNC_QUERIES": True,
}

SCREENSHOT_SELENIUM_DRIVER = "firefox"
WEBDRIVER_TYPE = "firefox"
WEBDRIVER_OPTION_ARGS = ["--headless", "--disable-dev-shm-usage"]
WEBDRIVER_CONFIGURATION = {"service_log_path": "/app/superset_home/geckodriver.log"}

SCREENSHOT_LOAD_WAIT = 120
SCREENSHOT_SELENIUM_ANIMATION_WAIT = 30
SCREENSHOT_SELENIUM_HEADSTART = 10
SCREENSHOT_LOCATE_WAIT = 120
EMAIL_PAGE_RENDER_WAIT = 45
SCREENSHOT_SELENIUM_WAIT = 45

WEBDRIVER_WINDOW = {"dashboard": (1300, 2000), "slice": (1300, 1200), "pixel_density": 1}

# ───────────────────────── GAQ (Async Queries) ────────────
GLOBAL_ASYNC_QUERIES_JWT_SECRET = os.getenv("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
ASYNC_QUERIES_JWT_ALGO = os.getenv("ASYNC_QUERIES_JWT_ALGO", "HS256")
GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
"CACHE_TYPE": "RedisCache",
"CACHE_KEY_PREFIX": "gaq_",
"CACHE_REDIS_HOST": "redis",
"CACHE_REDIS_PORT": 6379,
"CACHE_DEFAULT_TIMEOUT": 0,
}
GLOBAL_ASYNC_QUERIES_REDIS_CONFIG = {"host": "redis", "port": 6379, "db": 0, "password": None, "ssl": False}
GLOBAL_ASYNC_QUERIES_TRANSPORT = "ws"
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "wss://superset.example.com/ws/"
GLOBAL_ASYNC_QUERIES_POLLING_DELAY = 300
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME = "async-token"
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = True

# ───────────────────────── Embedding / CSP / CORS ─────────
GUEST_ROLE_NAME = "Gamma"
GUEST_TOKEN_JWT_SECRET = os.getenv("GUEST_TOKEN_JWT_SECRET")
GUEST_TOKEN_JWT_ALGO = "HS256"
EMBEDDED_SUPERSET_CONFIG = {"guest_token_jwt_audience": "superset", "allowed_domains": ["https://superset.example.com"]}

TALISMAN_ENABLED = True
TALISMAN_CONFIG = {
"force_https": False,
"content_security_policy": {"frame-ancestors": ["'self'", "https://superset.example.com"]},
}

ENABLE_CORS = False
CORS_OPTIONS = {"supports_credentials": True, "resources": [r"/api/.*"], "origins": ["https://superset.example.com"]}

MAPBOX_API_KEY = os.getenv("MAPBOX_API_KEY")
LOG_LEVEL = "INFO"

Reverse proxy (NPM)

Keep your main proxy host pointing to http://superset:8088.
Add a Custom Location:

Location: /ws/

Forward to: http://websocket:8080

Enable Websockets Support

Advanced tab:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 600s;
proxy_send_timeout 600s;

Make sure NPM is on the same Docker network as this stack.

How to confirm it works

WebSocket: open DevTools > Network > filter WS > you should see wss://bi.yourdomain.tld/ws/ with 101 Switching Protocols and messages flowing.

Reports: create a test Report > "Run now" > you'll see activity in superset_worker logs; you should receive an email.

Speed: charts appear as soon as each query finishes (no synchronized pop after a poll tick).