fix(docker): the panel image could not boot, and compose started nothing

Two independent bugs, both surfaced by issue #96.

The image died on every run with "sqlite3.OperationalError: unable to open
database file": config.py defaults DATABASE_URL to
sqlite:////app/instance/serverkit.db, the Dockerfile never created
/app/instance, and SQLAlchemy will not create a parent directory. Creating it
before the USER switch also fixes named-volume ownership -- Docker seeds an
empty volume from the image directory, permissions included, but creates it
root-owned when the path is absent from the image.

Separately, every docker-compose.yml service sat behind the legacy-frontend
profile, so `docker compose up -d` started nothing at all while the README and
install guide both gave that command as the Docker install. Following the
profile hint yielded the legacy nginx container alone -- a static file server
plus a proxy to a backend on the host -- serving an empty bind mount, which is
the 403 the reporter saw. Default `up -d` now runs the all-in-one image on
:5000 with a named volume; the legacy service stays profiled and says in a
comment that it needs a host install.

DATABASE_URL is pinned rather than interpolated. .env.example shipped a
*relative* sqlite path, which resolves against the container WORKDIR instead of
the volume and is destroyed by the next `compose down`; anyone who copied that
.env would inherit the trap. SERVERKIT_DATABASE_URL is the documented override.

Verified against a real build: boots healthy, serves the SPA, survives a
container recreate on the volume with 143 tables intact, and comes up with no
.env present at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 5 committed 2026-08-13 23:05:55 -04:00
1 parent 00f87d26de
commit de9523774c
3 files changed
+126 -23

No files matched your search

+16 -5
View File
@@ -18,8 +18,9 @@ SERVERKIT_ENCRYPTION_KEY=your-fernet-key-here
# ----------------------------------------
# Database Configuration
# ----------------------------------------
# SQLite (default - good for small deployments):
DATABASE_URL=sqlite:///serverkit.db
# SQLite (default - good for small deployments).
# NOTE the path is relative to the backend working directory.
# DATABASE_URL=sqlite:///serverkit.db
# PostgreSQL (recommended for production):
# DATABASE_URL=postgresql://user:password@localhost:5432/serverkit
@@ -27,6 +28,14 @@ DATABASE_URL=sqlite:///serverkit.db
# MySQL:
# DATABASE_URL=mysql://user:password@localhost:3306/serverkit
# ── Under Docker, use SERVERKIT_DATABASE_URL instead ──
# docker-compose.yml pins DATABASE_URL to /app/instance/serverkit.db, the path
# backed by the persistent volume, and ignores the DATABASE_URL above. That is
# on purpose: a *relative* sqlite path resolves inside the container and is
# destroyed by the next `compose down`. To point Docker at another database,
# set this instead — it wins:
# SERVERKIT_DATABASE_URL=postgresql://user:password@db:5432/serverkit
# ----------------------------------------
# Network Configuration
# ----------------------------------------
@@ -40,9 +49,11 @@ CORS_ORIGINS=http://localhost,https://your-domain.com
# Leave commented out for plain localhost development.
# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com
# Ports (for Docker)
PORT=80
SSL_PORT=443
# Port. Only the Flask *development* server reads this (`python run.py`).
# Under Docker the container always listens on 5000 and you publish it with
# SERVERKIT_HTTP_PORT (see docker-compose.yml); under gunicorn the port comes
# from SERVERKIT_BACKEND_PORT.
# PORT=5000
# ----------------------------------------
# Trusted reverse proxy (client IP)
+11 -2
View File
@@ -133,8 +133,17 @@ RUN groupadd -r serverkit && useradd -r -g serverkit serverkit
# so runtime extension installs (PluginService's `pip install -r`) can write to it.
COPY --from=python-builder --chown=serverkit:serverkit /opt/venv /opt/venv
# Create necessary directories
RUN mkdir -p /etc/serverkit /var/serverkit/apps /var/log/serverkit /var/quarantine /var/backups/serverkit /app/data \
# Create necessary directories.
#
# /app/instance is where config.py's default DATABASE_URL points
# (sqlite:////app/instance/serverkit.db). It must exist AND be owned by the
# serverkit user before the USER switch below: SQLAlchemy will not create the
# parent directory, so without this the container dies on boot with
# "sqlite3.OperationalError: unable to open database file". Creating it here
# also fixes the ownership of a named volume mounted at that path — Docker
# seeds an empty volume from the image directory, permissions included, but
# creates it root-owned when the path is absent from the image.
RUN mkdir -p /etc/serverkit /var/serverkit/apps /var/log/serverkit /var/quarantine /var/backups/serverkit /app/data /app/instance \
&& chown -R serverkit:serverkit /etc/serverkit /var/serverkit /var/log/serverkit /var/quarantine /var/backups/serverkit /app
# Set working directory
+99 -16
View File
@@ -1,27 +1,106 @@
# ============================================
# ServerKit Docker Compose (optional legacy frontend container)
# ServerKit Docker Compose
# ============================================
# ServerKit runs FULLY OFF DOCKER for the panel itself:
# - the backend runs on the host via systemd `serverkit.service`, and
# - the React frontend is served as static files directly by the host nginx
# from frontend/dist (built by install.sh) — NO container.
# This keeps a ServerKit update from ever touching the workloads: managed apps
# are the only things in Docker, and host nginx (which fronts them) is never
# stopped, only gracefully reloaded.
# `docker compose up -d` runs the all-in-one panel image (./Dockerfile):
# gunicorn serves the API, the React SPA and the Socket.IO websockets from ONE
# container on port 5000. Reach it at http://localhost:5000.
#
# The `frontend` service below is retained ONLY as an opt-in escape hatch for
# anyone who specifically wants the panel UI in a container. It sits behind the
# `legacy-frontend` profile, so the default `docker compose up -d` starts
# NOTHING. To use it, also repoint nginx `location /` back at 127.0.0.1:3847:
# There is NO nginx inside the container, and the app never redirects to HTTPS.
# That makes this the topology to use when you already run your own reverse
# proxy (Caddy, Traefik, Nginx Proxy Manager, …) with your own CA — point it at
# port 5000 and set SERVERKIT_PUBLIC_URL + TRUST_PROXY_HEADERS below. See
# docs/INSTALLATION.md → "Running behind your own reverse proxy".
#
# docker compose --profile legacy-frontend up -d --build
# ⚠ A containerised panel CANNOT manage its own host: no systemd, no host
# nginx, no host packages, so per-app vhosts and managed sites are out. It is
# the right shape for a demo, for driving OTHER servers through agents, and
# for a panel behind your own proxy. To manage THIS machine, use the host
# installer instead:
#
# Want a quick, throwaway demo with NO host management? Use the single-container
# image built from ./Dockerfile instead — it bundles both tiers but, as its
# header notes, cannot manage the host.
# curl -fsSL https://serverkit.ai/install.sh | sudo bash
#
# Before the first run:
# cp .env.example .env # then set SECRET_KEY / JWT_SECRET_KEY
# ============================================
services:
serverkit:
build:
context: .
dockerfile: Dockerfile
image: serverkit:local
# Prefer the published release image over building locally? Comment out the
# `build:` block above and use:
# image: jhd3197/serverkit:latest
container_name: serverkit
ports:
# Host port is overridable because 5000 collides with some AirPlay/dev
# setups; the container always listens on 5000.
- "${SERVERKIT_HTTP_PORT:-5000}:5000"
env_file:
# required:false so a missing .env warns instead of aborting. The panel
# does boot without one — but on config.py's built-in dev SECRET_KEY and
# JWT_SECRET_KEY, which are public knowledge. Fine for a five-minute look,
# never for anything reachable. `cp .env.example .env` and set both.
# (Needs Compose >= 2.24 for this long form.)
- path: .env
required: false
environment:
# Canonical database location: on the named volume, so `compose down`
# keeps your data.
#
# Deliberately NOT `${DATABASE_URL:-...}`. .env.example used to ship a
# *relative* `sqlite:///serverkit.db`, which resolves against the
# container WORKDIR (/app/backend) — off the volume, and silently
# destroyed by the next `compose down`. Anyone who copied that .env would
# inherit the trap. An `environment:` entry outranks `env_file:`, so this
# line neutralises it.
#
# To use PostgreSQL (or another absolute path), set SERVERKIT_DATABASE_URL
# in .env — DATABASE_URL there is ignored under Docker by design:
# SERVERKIT_DATABASE_URL=postgresql://user:pass@db:5432/serverkit
DATABASE_URL: ${SERVERKIT_DATABASE_URL:-sqlite:////app/instance/serverkit.db}
# Required when a reverse proxy terminates TLS in front of this container:
# the Socket.IO origin check rejects the browser's handshake without it.
# SERVERKIT_PUBLIC_URL=https://panel.example.com
#
# TRUST_PROXY_HEADERS makes ServerKit derive the real client IP from
# X-Forwarded-For (rate limits, login lockout, audit logs) instead of
# logging every request as the proxy's address. Leave it off unless a
# proxy really is in front — on a directly-exposed port the header is
# client-forgeable. Both are read from .env.
FLASK_ENV: ${FLASK_ENV:-production}
volumes:
- serverkit-data:/app/instance
# Let the panel manage Docker containers on this host. ⚠ The docker socket
# is root-equivalent on the host — mounting it means a panel compromise is
# a host compromise. Opt in deliberately:
# - /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/v1/system/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# --------------------------------------------
# Legacy: panel UI in its own nginx container.
# --------------------------------------------
# Only for a HOST install (`install.sh`), where the backend already runs on
# the host under systemd and `frontend/dist` has been built there — this
# service is a static-file server plus a proxy to the host, nothing more. On
# its own it serves an empty directory and nginx answers 403.
#
# It sits behind a profile, so plain `docker compose up -d` never starts it:
# docker compose --profile legacy-frontend up -d --build
#
# Then repoint the host nginx `location /` back at 127.0.0.1:3847.
frontend:
profiles: ["legacy-frontend"]
build:
@@ -52,6 +131,10 @@ services:
max-size: "10m"
max-file: "3"
volumes:
serverkit-data:
name: serverkit-data
networks:
serverkit-network:
driver: bridge