From de9523774c6eaeeb21243cdf0ea45f7d90a85afd Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Thu, 13 Aug 2026 23:05:55 -0400 Subject: [PATCH] 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) --- .env.example | 21 +++++++-- Dockerfile | 13 ++++- docker-compose.yml | 115 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index fbc7af7d..5142fdb4 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/Dockerfile b/Dockerfile index d034f812..e4529ded 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 021fe422..749fa3ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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