diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..239ad9ea
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,23 @@
+.git
+.venv
+venv
+__pycache__
+*.pyc
+.pytest_cache
+
+frontend/node_modules
+frontend/dist
+frontend/*.tsbuildinfo
+backend/.pytest_cache
+backend/**/*.pyc
+backend/**/__pycache__
+
+storage
+postgres-data
+datasets/raw
+datasets/processed
+datasets/cache
+exports
+models
+
+.env
diff --git a/.env.example b/.env.example
index 95b5da12..22c7fbac 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,7 @@ VITE_MAP_STYLE_URL=https://demotiles.maplibre.org/style.json
GEOINTEL_FRONTEND_PORT=1202
GEOINTEL_BACKEND_PORT=8000
GEOINTEL_STORAGE_PATH=./storage
+GEOINTEL_POSTGIS_DATA_PATH=./postgres-data
GEOINTEL_POSTGRES_DB=geointel
GEOINTEL_POSTGRES_USER=geointel
GEOINTEL_POSTGRES_PASSWORD=geointel
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42e884ea..30745837 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@
# Changelog
+## Sprint 32 Unraid all-in-one runtime (2026-06-17)
+
+- Added `docker-compose.unraid.yml` for a single `geointel` container on Unraid.
+- Added `deploy/unraid/Dockerfile.all-in-one`, embedding PostGIS, FastAPI, nginx and the built React frontend in one image.
+- Added `deploy/unraid/all-in-one-start.sh` to start embedded PostGIS, apply Alembic migrations, start the backend and serve nginx.
+- Added `deploy/unraid/nginx-all-in-one.conf` with localhost backend proxying inside the same container.
+- Updated Tower deploy scripts to stop the old multi-container stack without removing volumes and start the all-in-one stack.
+- Updated the Unraid template so the Docker can be edited from Unraid with one web port, storage path, PostGIS data path and app icon.
+- No API contracts, migrations, product features, provider fetching or AI behavior were introduced.
+
## Sprint 31 Unraid deployment template (2026-06-17)
- Made Docker Compose ports, storage path, PostGIS credentials, CORS origins and upload limit configurable through `.env` defaults.
diff --git a/README.md b/README.md
index 595e26bb..63d1e35a 100644
--- a/README.md
+++ b/README.md
@@ -69,32 +69,33 @@ make readiness
## Unraid / Tower deployment
-GeoIntel runs on Unraid as a Docker Compose stack with PostGIS, backend and frontend services. The frontend is the browser entrypoint and proxies `/api` and `/health` to the backend.
+GeoIntel runs on Unraid as an all-in-one Docker container. The container embeds PostGIS, runs the FastAPI backend internally, and serves the frontend through nginx on one editable web port.
Unraid template assets live in:
- `deploy/unraid/geointel.env.example`
- `deploy/unraid/geointel-unraid-template.xml`
- `deploy/unraid/geointel-icon.svg`
+- `docker-compose.unraid.yml`
-Copy the Unraid env template to `.env` in the checkout and edit ports there:
+Copy the Unraid env template to `.env` in the checkout and edit ports/paths there:
```bash
cd /mnt/user/appdata/geointel
cp deploy/unraid/geointel.env.example .env
nano .env
-docker compose up -d --build
+docker compose -f docker-compose.unraid.yml up -d --build
```
Common editable values:
```env
GEOINTEL_FRONTEND_PORT=1202
-GEOINTEL_BACKEND_PORT=8000
GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
+GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
```
-The database port is intentionally not exposed to the LAN. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes.
+The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes.
## Sprint 2 quick start
diff --git a/backend/tests/test_sprint31_unraid_template.py b/backend/tests/test_sprint31_unraid_template.py
index 85998e48..46607c4e 100644
--- a/backend/tests/test_sprint31_unraid_template.py
+++ b/backend/tests/test_sprint31_unraid_template.py
@@ -10,24 +10,25 @@ def test_unraid_template_documents_editable_runtime_settings() -> None:
template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
assert "GeoIntel Kempen" in template
- assert "GeoIntel is a multi-container Docker Compose stack" in template
- assert "http://[IP]:[PORT:1202]/" in template
- assert "http://[IP]:[PORT:1202]/geointel-icon.svg" in template
- assert 'Target="GEOINTEL_FRONTEND_PORT"' in template
- assert 'Target="GEOINTEL_BACKEND_PORT"' in template
- assert 'Target="GEOINTEL_STORAGE_PATH"' in template
+ assert "GeoIntel all-in-one runs the complete GeoIntel Kempen V1 stack in one Docker container" in template
+ assert "geointel-all-in-one:latest" in template
+ assert "http://[IP]:[PORT:80]/" in template
+ assert "http://[IP]:[PORT:80]/geointel-icon.svg" in template
+ assert 'Target="80"' in template
+ assert 'Target="/app/storage"' in template
+ assert 'Target="/var/lib/postgresql/data"' in template
assert 'Target="GEOINTEL_POSTGRES_PASSWORD"' in template
assert 'Mask="true">change-me-before-shared-use' in template
-def test_unraid_env_template_matches_compose_variables() -> None:
- compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+def test_unraid_env_template_matches_single_container_compose_variables() -> None:
+ compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")
env_template = (ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8")
for key in (
"GEOINTEL_FRONTEND_PORT",
- "GEOINTEL_BACKEND_PORT",
"GEOINTEL_STORAGE_PATH",
+ "GEOINTEL_POSTGIS_DATA_PATH",
"GEOINTEL_POSTGRES_DB",
"GEOINTEL_POSTGRES_USER",
"GEOINTEL_POSTGRES_PASSWORD",
@@ -39,6 +40,14 @@ def test_unraid_env_template_matches_compose_variables() -> None:
assert "GEOINTEL_FRONTEND_PORT=1202" in env_template
assert "GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage" in env_template
+ assert "GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data" in env_template
+ assert "${GEOINTEL_POSTGIS_DATA_PATH:-geointel_postgis}:/var/lib/postgresql/data" in compose
+ assert "geointel_postgis:" in compose
+ assert "geointel:" in compose
+ assert "db:" not in compose
+ assert "backend:" not in compose
+ assert "frontend:" not in compose
+ assert '"${GEOINTEL_FRONTEND_PORT:-1202}:80"' in compose
def test_unraid_readme_explains_port_changes_and_safe_cleanup() -> None:
@@ -46,12 +55,44 @@ def test_unraid_readme_explains_port_changes_and_safe_cleanup() -> None:
assert "cp deploy/unraid/geointel.env.example .env" in readme
assert "GEOINTEL_FRONTEND_PORT=1203" in readme
- assert "docker compose up -d --build" in readme
- assert "bash scripts/live_migration_smoke.sh" in readme
+ assert "docker compose -f docker-compose.unraid.yml up -d --build" in readme
+ assert "curl -fsS" in readme
assert "docker builder prune -af" in readme
assert "Avoid broad volume pruning" in readme
+def test_unraid_all_in_one_runtime_starts_embedded_postgis_backend_and_nginx() -> None:
+ dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
+ start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8")
+ nginx_config = (ROOT / "deploy" / "unraid" / "nginx-all-in-one.conf").read_text(encoding="utf-8")
+ dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
+
+ assert "FROM postgis/postgis:16-3.4 AS runtime" in dockerfile
+ assert "COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/" in dockerfile
+ assert 'CMD ["/usr/local/bin/geointel-all-in-one-start"]' in dockerfile
+ assert "/usr/local/bin/docker-entrypoint.sh postgres &" in start_script
+ assert "python -m alembic upgrade head" in start_script
+ assert "uvicorn app.main:app --host 127.0.0.1 --port 8000 &" in start_script
+ assert 'exec nginx -g "daemon off;"' in start_script
+ assert "proxy_pass http://127.0.0.1:8000/api/" in nginx_config
+ assert "proxy_pass http://127.0.0.1:8000/health" in nginx_config
+ assert "frontend/node_modules" in dockerignore
+ assert "storage" in dockerignore
+ assert "postgres-data" in dockerignore
+
+
+def test_tower_deploy_uses_single_container_unraid_compose() -> None:
+ powershell = (ROOT / "scripts" / "deploy_tower.ps1").read_text(encoding="utf-8")
+ bash = (ROOT / "scripts" / "deploy_tower.sh").read_text(encoding="utf-8")
+
+ for script in (powershell, bash):
+ assert "docker compose down --remove-orphans || true" in script
+ assert "docker compose -f docker-compose.unraid.yml config" in script
+ assert "docker compose -f docker-compose.unraid.yml build geointel" in script
+ assert "docker compose -f docker-compose.unraid.yml up -d" in script
+ assert "COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh" in script
+
+
def test_frontend_and_unraid_icon_assets_are_present() -> None:
deploy_icon = (ROOT / "deploy" / "unraid" / "geointel-icon.svg").read_text(encoding="utf-8")
frontend_icon = (ROOT / "frontend" / "public" / "geointel-icon.svg").read_text(encoding="utf-8")
diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one
new file mode 100644
index 00000000..38d7f5dd
--- /dev/null
+++ b/deploy/unraid/Dockerfile.all-in-one
@@ -0,0 +1,58 @@
+FROM node:20-alpine AS frontend-build
+
+WORKDIR /frontend
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm install
+COPY frontend/ ./
+RUN npm run build
+
+FROM postgis/postgis:16-3.4 AS runtime
+
+ENV GEOINTEL_ENV=production \
+ GEOINTEL_API_PREFIX=/api/v1 \
+ GEOINTEL_STORAGE_ROOT=/app/storage \
+ STORAGE_ROOT=/app/storage \
+ GEOINTEL_POSTGRES_DB=geointel \
+ GEOINTEL_POSTGRES_USER=geointel \
+ GEOINTEL_POSTGRES_PASSWORD=geointel \
+ GEOINTEL_ALL_IN_ONE=1 \
+ PATH="/opt/geointel/venv/bin:${PATH}"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ curl \
+ gdal-bin \
+ libgdal-dev \
+ libgeos-dev \
+ libpq-dev \
+ libproj-dev \
+ nginx \
+ proj-bin \
+ python3 \
+ python3-pip \
+ python3-venv \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY backend/ /app/
+COPY fixtures/ /app/fixtures/
+COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf
+COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start
+COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/
+
+RUN python3 -m venv /opt/geointel/venv \
+ && pip install --no-cache-dir --upgrade pip setuptools \
+ && pip install --no-cache-dir ".[gis]" \
+ && python scripts/gis_import_smoke.py \
+ && chmod +x /usr/local/bin/geointel-all-in-one-start \
+ && mkdir -p /app/storage /run/nginx /var/log/nginx
+
+VOLUME ["/var/lib/postgresql/data", "/app/storage"]
+
+EXPOSE 80
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=10 --start-period=60s \
+ CMD curl -fsS http://127.0.0.1/health >/dev/null || exit 1
+
+CMD ["/usr/local/bin/geointel-all-in-one-start"]
diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md
index b0396dfc..4f6509ad 100644
--- a/deploy/unraid/README.md
+++ b/deploy/unraid/README.md
@@ -1,30 +1,38 @@
-# GeoIntel Unraid template
+# GeoIntel Unraid all-in-one container
-GeoIntel is a multi-container Docker Compose stack:
+GeoIntel can run on Unraid as one Docker container.
-- `db`: PostGIS 16 / PostGIS 3.4
-- `backend`: FastAPI/GIS runtime
-- `frontend`: nginx-served React app with `/api` and `/health` proxying to the backend
+Inside that single container:
-The normal browser entrypoint is:
+- embedded PostGIS stores the application database
+- Alembic migrations run at startup
+- FastAPI runs on internal `127.0.0.1:8000`
+- nginx serves the React/MapLibre frontend on container port `80`
+- nginx proxies `/api` and `/health` to the internal backend
+
+The browser entrypoint is:
```text
http://:${GEOINTEL_FRONTEND_PORT}
```
-## Files
-
-- `geointel.env.example`: copy this to the repository checkout as `.env`.
-- `geointel-unraid-template.xml`: Unraid/DockerMan-style metadata for the editable settings.
-- `geointel-icon.svg`: icon source for template/app metadata.
-
-The frontend also serves the same icon at:
+The app icon is served from the same container:
```text
http://:${GEOINTEL_FRONTEND_PORT}/geointel-icon.svg
```
-## First setup on Unraid
+## Files
+
+- `docker-compose.unraid.yml`: recommended single-container Compose stack.
+- `deploy/unraid/Dockerfile.all-in-one`: builds the single container.
+- `deploy/unraid/all-in-one-start.sh`: starts embedded PostGIS, backend and nginx.
+- `deploy/unraid/nginx-all-in-one.conf`: frontend and API proxy config for one container.
+- `deploy/unraid/geointel.env.example`: copy to `.env` and edit ports/paths.
+- `deploy/unraid/geointel-unraid-template.xml`: Unraid/DockerMan metadata for editable fields.
+- `deploy/unraid/geointel-icon.svg`: icon source.
+
+## First setup with Compose Manager
From the Unraid shell:
@@ -33,59 +41,46 @@ cd /mnt/user/appdata
git clone gitea-widefrog:NuklearRabbit/geointel.git geointel
cd /mnt/user/appdata/geointel
cp deploy/unraid/geointel.env.example .env
-```
-
-Edit `.env` before starting the stack:
-
-```bash
nano .env
-```
-
-Common values:
-
-```env
-GEOINTEL_FRONTEND_PORT=1202
-GEOINTEL_BACKEND_PORT=8000
-GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
-GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use
-GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202
-```
-
-Start or rebuild:
-
-```bash
-docker compose config
-docker compose up -d --build
+docker compose -f docker-compose.unraid.yml config
+docker compose -f docker-compose.unraid.yml up -d --build
```
Validate:
```bash
-bash scripts/live_migration_smoke.sh
-bash scripts/verify_browser_runtime.sh "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}"
+curl -fsS "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/health"
+curl -fsS "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/api/v1/projects"
+curl -I "http://192.168.10.150:${GEOINTEL_FRONTEND_PORT:-1202}/geointel-icon.svg"
```
-## Change ports
+## Change the browser port
-Change the web UI port:
+Edit `.env`:
```env
GEOINTEL_FRONTEND_PORT=1203
-```
-
-If you expose a different web port, also update CORS origins:
-
-```env
GEOINTEL_CORS_ORIGINS=http://localhost:1203,http://127.0.0.1:1203,http://192.168.10.150:1203
```
Apply:
```bash
-docker compose up -d --build
+docker compose -f docker-compose.unraid.yml up -d --build
```
-The database port is intentionally not published to the LAN. The backend reaches it through the internal Compose service name `db`.
+## Persistent paths
+
+Recommended Unraid paths:
+
+```env
+GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
+GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
+```
+
+`GEOINTEL_STORAGE_PATH` contains uploads, tiles, masks, reports and exports.
+
+`GEOINTEL_POSTGIS_DATA_PATH` contains the embedded PostGIS database files.
## Update from Gitea
@@ -93,11 +88,10 @@ The database port is intentionally not published to the LAN. The backend reaches
cd /mnt/user/appdata/geointel
git fetch origin main
git reset --hard origin/main
-docker compose up -d --build
-bash scripts/live_migration_smoke.sh
+docker compose -f docker-compose.unraid.yml up -d --build
```
-## Cleanup notes
+## Safe cleanup
Safe cache cleanup if Docker build cache fills the Unraid Docker image:
@@ -106,3 +100,7 @@ docker builder prune -af
```
Avoid broad volume pruning unless you explicitly intend to remove persisted PostGIS data or GeoIntel artifacts.
+
+## Multi-container development stack
+
+The root `docker-compose.yml` remains available for development and CI-like validation with separate `db`, `backend` and `frontend` services. For Unraid app-style operation, prefer `docker-compose.unraid.yml`.
diff --git a/deploy/unraid/all-in-one-start.sh b/deploy/unraid/all-in-one-start.sh
new file mode 100644
index 00000000..adb7f35d
--- /dev/null
+++ b/deploy/unraid/all-in-one-start.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+export POSTGRES_DB="${GEOINTEL_POSTGRES_DB:-${POSTGRES_DB:-geointel}}"
+export POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-${POSTGRES_USER:-geointel}}"
+export POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-geointel}}"
+export PGDATA="${PGDATA:-/var/lib/postgresql/data}"
+export STORAGE_ROOT="${STORAGE_ROOT:-${GEOINTEL_STORAGE_ROOT:-/app/storage}}"
+export DATABASE_URL="${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}}"
+export CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-${CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}}"
+export MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-${MAX_UPLOAD_MB:-500}}"
+
+mkdir -p "$PGDATA" "$STORAGE_ROOT" /run/nginx /var/log/nginx
+chown -R postgres:postgres "$PGDATA"
+
+postgres_pid=""
+backend_pid=""
+
+shutdown() {
+ if [ -n "$backend_pid" ] && kill -0 "$backend_pid" 2>/dev/null; then
+ kill "$backend_pid" 2>/dev/null || true
+ fi
+ if [ -n "$postgres_pid" ] && kill -0 "$postgres_pid" 2>/dev/null; then
+ kill "$postgres_pid" 2>/dev/null || true
+ wait "$postgres_pid" 2>/dev/null || true
+ fi
+}
+trap shutdown INT TERM EXIT
+
+echo "Starting embedded PostGIS..."
+/usr/local/bin/docker-entrypoint.sh postgres &
+postgres_pid="$!"
+
+echo "Waiting for embedded PostGIS..."
+for attempt in $(seq 1 60); do
+ if pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
+ echo "PostGIS is ready after attempt ${attempt}."
+ break
+ fi
+ if ! kill -0 "$postgres_pid" 2>/dev/null; then
+ echo "PostGIS process exited before becoming ready."
+ wait "$postgres_pid"
+ fi
+ sleep 2
+done
+
+if ! pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
+ echo "PostGIS did not become ready."
+ exit 1
+fi
+
+echo "Applying Alembic migrations..."
+python -m alembic upgrade head
+
+echo "Starting GeoIntel backend..."
+uvicorn app.main:app --host 127.0.0.1 --port 8000 &
+backend_pid="$!"
+
+echo "Waiting for GeoIntel backend..."
+python - <<'PY'
+import time
+import urllib.request
+
+last_error = None
+for attempt in range(1, 61):
+ try:
+ urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=3).read()
+ print(f"Backend is ready after attempt {attempt}.")
+ break
+ except Exception as exc:
+ last_error = exc
+ print(f"Backend not ready yet ({attempt}/60): {exc}")
+ time.sleep(1)
+else:
+ raise SystemExit(f"Backend did not become ready: {last_error}")
+PY
+
+echo "Starting nginx frontend on container port 80..."
+exec nginx -g "daemon off;"
diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml
index 59bade22..fd71d3f6 100644
--- a/deploy/unraid/geointel-unraid-template.xml
+++ b/deploy/unraid/geointel-unraid-template.xml
@@ -1,18 +1,18 @@
GeoIntel Kempen
- Local Docker Compose stack
+ geointel-all-in-one:latest
gitea-widefrog:NuklearRabbit/geointel.git
bridge
- sh
+ bash
false
http://192.168.10.150:1202
GeoIntel Kempen
- GeoIntel is a multi-container Docker Compose stack for the GeoIntel Kempen GeoAI Workbench: PostGIS, FastAPI and React/MapLibre. Use this template together with deploy/unraid/geointel.env.example and docker-compose.yml; it documents the editable Unraid settings for the Compose stack.
+ GeoIntel all-in-one runs the complete GeoIntel Kempen V1 stack in one Docker container: embedded PostGIS, FastAPI backend, nginx frontend and MapLibre UI. Use docker-compose.unraid.yml or this template so the web port, storage path and database path can be edited from Unraid.
Productivity: Tools: GIS:
- http://[IP]:[PORT:1202]/
+ http://[IP]:[PORT:80]/
deploy/unraid/geointel-unraid-template.xml
- http://[IP]:[PORT:1202]/geointel-icon.svg
+ http://[IP]:[PORT:80]/geointel-icon.svg
@@ -20,14 +20,14 @@
- GeoIntel Kempen runs as a Docker Compose stack with services db, backend and frontend. Edit the matching .env variables to change ports and paths, then run docker compose up -d --build from /mnt/user/appdata/geointel.
+ Single-container GeoIntel runtime for Unraid. The container starts embedded PostGIS, runs Alembic migrations, starts the FastAPI backend on localhost and serves the frontend through nginx on container port 80.
- 1202
- 8000
- /mnt/user/appdata/geointel/storage
- geointel
- geointel
- change-me-before-shared-use
+ 1202
+ /mnt/user/appdata/geointel/storage
+ /mnt/user/appdata/geointel/postgres-data
+ geointel
+ geointel
+ change-me-before-shared-use
http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202
500
diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example
index fa6043f9..0669bfb8 100644
--- a/deploy/unraid/geointel.env.example
+++ b/deploy/unraid/geointel.env.example
@@ -1,17 +1,16 @@
-# GeoIntel Unraid/Compose environment template.
+# GeoIntel Unraid all-in-one environment template.
# Copy this file to /mnt/user/appdata/geointel/.env and edit values there.
# Browser URL: http://:
GEOINTEL_FRONTEND_PORT=1202
-# Optional direct backend API port. The frontend proxies /api and /health, so this
-# does not need to be exposed to normal browser users.
-GEOINTEL_BACKEND_PORT=8000
-
# Persisted application artifacts: uploads, tiles, masks, reports and exports.
GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
-# Internal PostGIS database settings. The database is not published to the LAN.
+# Embedded PostGIS data directory for the all-in-one container.
+GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
+
+# Internal embedded PostGIS settings. The database is not published to the LAN.
GEOINTEL_POSTGRES_DB=geointel
GEOINTEL_POSTGRES_USER=geointel
GEOINTEL_POSTGRES_PASSWORD=change-me-before-shared-use
diff --git a/deploy/unraid/nginx-all-in-one.conf b/deploy/unraid/nginx-all-in-one.conf
new file mode 100644
index 00000000..6f92f4b9
--- /dev/null
+++ b/deploy/unraid/nginx-all-in-one.conf
@@ -0,0 +1,44 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location = /index.html {
+ add_header Cache-Control "no-cache";
+ try_files /index.html =404;
+ }
+
+ location = /geointel-icon.svg {
+ add_header Cache-Control "public, max-age=3600";
+ try_files /geointel-icon.svg =404;
+ }
+
+ location /assets/ {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+ }
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8000/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location = /health {
+ proxy_pass http://127.0.0.1:8000/health;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml
new file mode 100644
index 00000000..49746c7b
--- /dev/null
+++ b/docker-compose.unraid.yml
@@ -0,0 +1,23 @@
+services:
+ geointel:
+ build:
+ context: .
+ dockerfile: deploy/unraid/Dockerfile.all-in-one
+ image: geointel-all-in-one:latest
+ container_name: geointel
+ environment:
+ GEOINTEL_POSTGRES_DB: ${GEOINTEL_POSTGRES_DB:-geointel}
+ GEOINTEL_POSTGRES_USER: ${GEOINTEL_POSTGRES_USER:-geointel}
+ GEOINTEL_POSTGRES_PASSWORD: ${GEOINTEL_POSTGRES_PASSWORD:-geointel}
+ GEOINTEL_STORAGE_ROOT: /app/storage
+ GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
+ GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
+ ports:
+ - "${GEOINTEL_FRONTEND_PORT:-1202}:80"
+ volumes:
+ - ${GEOINTEL_POSTGIS_DATA_PATH:-geointel_postgis}:/var/lib/postgresql/data
+ - ${GEOINTEL_STORAGE_PATH:-./storage}:/app/storage
+ restart: unless-stopped
+
+volumes:
+ geointel_postgis:
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 64f11a65..b1862161 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -1,3 +1,32 @@
+## Sprint 32 Unraid all-in-one runtime (2026-06-17)
+
+Changed:
+- Added `docker-compose.unraid.yml` for a single editable `geointel` container on Unraid.
+- Added `deploy/unraid/Dockerfile.all-in-one` to build one image containing embedded PostGIS, backend GIS runtime, nginx and frontend static assets.
+- Added `deploy/unraid/all-in-one-start.sh` to start embedded PostGIS, apply Alembic migrations, start FastAPI on internal localhost and serve nginx on container port 80.
+- Added `deploy/unraid/nginx-all-in-one.conf` so `/api` and `/health` proxy to `127.0.0.1:8000` inside the same container.
+- Updated `deploy/unraid/geointel.env.example`, Unraid XML template and README for one-container operation and editable web/storage/database paths.
+- Updated Tower deploy scripts to stop the old multi-container stack without deleting volumes and start `docker-compose.unraid.yml`.
+- Updated `scripts/live_migration_smoke.sh` to support both all-in-one `geointel` and legacy `backend` Compose services.
+- Added root `.dockerignore` for all-in-one builds.
+- Updated Sprint 31 tests to cover the all-in-one Dockerfile, startscript, nginx config, deploy scripts and template metadata.
+
+Validation:
+- `python -m pytest backend/tests/test_sprint31_unraid_template.py backend/tests/test_live_migration_smoke_script.py backend/tests/test_docker_runtime_config.py` passed: 26 tests.
+- `cd frontend && npm run typecheck` passed.
+- `cd frontend && npm run build` passed.
+- `python -m compileall backend/app` passed.
+- `cd backend && python -m pytest` passed: 179 tests.
+- `bash scripts/run_readiness_check.sh` passed: 179 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
+- `cd backend && python -m alembic heads` passed: single head `202606120900`.
+- `cd backend && python -m alembic upgrade head --sql` passed.
+- `bash -n scripts/live_migration_smoke.sh` passed.
+- `bash -n deploy/unraid/all-in-one-start.sh` passed.
+
+Notes:
+- No API contracts, migrations, product features, provider fetching or AI behavior changed.
+- Local Windows environment does not have `docker` in PATH; Tower deployment should provide the live all-in-one Docker validation.
+
## Sprint 31 Unraid deployment template (2026-06-17)
Changed:
diff --git a/docs/TODO.md b/docs/TODO.md
index 892aba6d..5bcd483f 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -11,6 +11,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add Docker build context ignores for backend and frontend.
- [x] Run Docker/PostGIS live validation on Tower/Unraid.
- [x] Add Unraid Compose template assets with editable ports, storage path and app icon.
+- [x] Add single-container Unraid runtime with embedded PostGIS, backend and frontend.
## Current implementation status
@@ -43,6 +44,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Dataset detail, raster controls and vector controls component decomposition.
- [x] QA/QC results and map workspace component decomposition.
- [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid.
+- [x] Single-container `geointel` Unraid compose/template runtime.
- [ ] Further frontend component decomposition for export preview and shared workbench orchestration.
## Sprint 8 status
diff --git a/scripts/deploy_tower.ps1 b/scripts/deploy_tower.ps1
index 56bb27c4..511cec11 100644
--- a/scripts/deploy_tower.ps1
+++ b/scripts/deploy_tower.ps1
@@ -30,16 +30,17 @@ fi
git remote get-url origin >/dev/null 2>&1 || git remote add origin '$RemoteRepo'
git fetch origin '$RemoteBranch'
git reset --hard 'origin/$RemoteBranch'
-git branch -M '$RemoteBranch'
-chmod +x scripts/*.sh backend/docker_start.sh || true
+ git branch -M '$RemoteBranch'
+chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true
-docker compose config >/dev/null
-docker compose build backend frontend
-docker compose up -d
-docker compose ps
+docker compose down --remove-orphans || true
+docker compose -f docker-compose.unraid.yml config >/dev/null
+docker compose -f docker-compose.unraid.yml build geointel
+docker compose -f docker-compose.unraid.yml up -d
+docker compose -f docker-compose.unraid.yml ps
if [ -x scripts/live_migration_smoke.sh ]; then
- bash scripts/live_migration_smoke.sh
+ COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh
fi
if [ -x scripts/verify_browser_runtime.sh ]; then
diff --git a/scripts/deploy_tower.sh b/scripts/deploy_tower.sh
index 13659c25..8e66a696 100755
--- a/scripts/deploy_tower.sh
+++ b/scripts/deploy_tower.sh
@@ -32,14 +32,16 @@ fi
git fetch origin "$REMOTE_BRANCH"
git checkout -B "$REMOTE_BRANCH" "origin/$REMOTE_BRANCH"
+chmod +x scripts/*.sh backend/docker_start.sh deploy/unraid/*.sh || true
-docker compose config >/dev/null
-docker compose build backend frontend
-docker compose up -d
-docker compose ps
+docker compose down --remove-orphans || true
+docker compose -f docker-compose.unraid.yml config >/dev/null
+docker compose -f docker-compose.unraid.yml build geointel
+docker compose -f docker-compose.unraid.yml up -d
+docker compose -f docker-compose.unraid.yml ps
if [[ -x scripts/live_migration_smoke.sh ]]; then
- bash scripts/live_migration_smoke.sh
+ COMPOSE_FILE=docker-compose.unraid.yml bash scripts/live_migration_smoke.sh
fi
if [[ -x scripts/verify_browser_runtime.sh ]]; then
diff --git a/scripts/live_migration_smoke.sh b/scripts/live_migration_smoke.sh
index 36f8f188..6f4f8100 100755
--- a/scripts/live_migration_smoke.sh
+++ b/scripts/live_migration_smoke.sh
@@ -4,14 +4,16 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-}"
LIVE_SMOKE_USE_DOCKER="${LIVE_SMOKE_USE_DOCKER:-auto}"
+COMPOSE_FILE_PATH="${COMPOSE_FILE:-docker-compose.yml}"
run_docker_smoke() {
+ service_name="$1"
cd "$ROOT"
echo "== GeoIntel live migration smoke =="
- echo "Using Docker Compose backend service."
+ echo "Using Docker Compose service: ${service_name}."
- docker compose exec -T backend sh <<'CONTAINER_SCRIPT'
+ docker compose -f "$COMPOSE_FILE_PATH" exec -T "$service_name" sh <<'CONTAINER_SCRIPT'
set -eu
cd /app
@@ -76,13 +78,18 @@ echo "== Live migration smoke passed =="
CONTAINER_SCRIPT
}
-if [ "$LIVE_SMOKE_USE_DOCKER" != "0" ] && command -v docker >/dev/null 2>&1 && [ -f "$ROOT/docker-compose.yml" ]; then
- if docker compose ps -q backend >/dev/null 2>&1 && [ -n "$(docker compose ps -q backend 2>/dev/null)" ]; then
- if [ "$LIVE_SMOKE_USE_DOCKER" = "1" ] || docker compose ps --status running backend | grep -q backend; then
- run_docker_smoke
- exit 0
+if [ "$LIVE_SMOKE_USE_DOCKER" != "0" ] && command -v docker >/dev/null 2>&1 && [ -f "$ROOT/$COMPOSE_FILE_PATH" ]; then
+ cd "$ROOT"
+ for service_name in geointel backend; do
+ if docker compose -f "$COMPOSE_FILE_PATH" ps -q "$service_name" >/dev/null 2>&1 \
+ && [ -n "$(docker compose -f "$COMPOSE_FILE_PATH" ps -q "$service_name" 2>/dev/null)" ]; then
+ if [ "$LIVE_SMOKE_USE_DOCKER" = "1" ] \
+ || docker compose -f "$COMPOSE_FILE_PATH" ps --status running "$service_name" | grep -q "$service_name"; then
+ run_docker_smoke "$service_name"
+ exit 0
+ fi
fi
- fi
+ done
fi
if [ -z "$PYTHON_BIN" ]; then