diff --git a/.env.example b/.env.example
index 210be31..65625fe 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,11 @@ GRAFANA_PASSWORD=change-this-password-in-production
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
+# Registration
+# Set to "false" to disable user registration. For prod, build the image with:
+# docker build --build-arg REGISTRATION_ENABLED=false -t your-image .
+REGISTRATION_ENABLED=true
+
# MinIO Configuration (Object Storage)
# MinIO server stays hidden - Flask proxies all requests
# MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication)
diff --git a/backend/app/config.py b/backend/app/config.py
index 5816b54..a318783 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -16,6 +16,7 @@ class Config:
MAX_FAILED_LOGIN_ATTEMPTS = int(os.environ.get("MAX_FAILED_LOGIN_ATTEMPTS", "5"))
ACCOUNT_LOCK_DURATION_MINUTES = int(os.environ.get("ACCOUNT_LOCK_DURATION_MINUTES", "30"))
LOCKOUT_STRATEGY = os.environ.get("LOCKOUT_STRATEGY", "timeout") # "timeout" | "permanent"
+ REGISTRATION_ENABLED = os.environ.get("REGISTRATION_ENABLED", "true").lower() == "true"
# Celery Configuration
CELERY = {
diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py
index 1eb3904..fa8adb7 100644
--- a/backend/app/routes/api.py
+++ b/backend/app/routes/api.py
@@ -1,6 +1,6 @@
"""Auth and user management API routes"""
-from flask import Blueprint, jsonify, request
+from flask import Blueprint, current_app, jsonify, request
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
@@ -24,6 +24,9 @@ api_bp = Blueprint("api", __name__)
@api_bp.route("/auth/register", methods=["POST"])
def register():
"""Register a new user"""
+ if not current_app.config.get("REGISTRATION_ENABLED", True):
+ return jsonify({"error": "Not found"}), 404
+
try:
data = request.get_json()
diff --git a/backend/gunicorn.conf.py b/backend/gunicorn.conf.py
new file mode 100644
index 0000000..3db3ff9
--- /dev/null
+++ b/backend/gunicorn.conf.py
@@ -0,0 +1,33 @@
+"""Gunicorn configuration.
+
+All Gunicorn settings live here instead of the Dockerfile CMD so they can be
+tweaked without rebuilding the image, and so the access-log format/timezone are
+defined in one place.
+"""
+
+import os
+import time
+
+# ---- Server ----
+bind = "0.0.0.0:8000"
+workers = 2
+
+# ---- Logging ----
+accesslog = "-"
+errorlog = "-"
+capture_output = True
+loglevel = "info"
+
+# %(h)s = real client IP. Gunicorn resolves X-Forwarded-For based on its built-in
+# proxy trust logic (the same mechanism already validated behind Traefik).
+# %(t)s = overridden by CustomLogger -> [2026-08-09 20:41:00 +0300]
+access_log_format = '%(h)s - - [%(t)s] "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
+logger_class = "gunicorn_logger:CustomLogger"
+
+# ---- Timezone for Python logging / error logs ----
+# Default to UTC unless TZ is set (e.g. TZ=Africa/Nairobi at docker run time).
+os.environ.setdefault("TZ", os.environ.get("TZ", "UTC"))
+try:
+ time.tzset()
+except AttributeError: # non-Unix
+ pass
\ No newline at end of file
diff --git a/backend/gunicorn_logger.py b/backend/gunicorn_logger.py
new file mode 100644
index 0000000..95fe7fc
--- /dev/null
+++ b/backend/gunicorn_logger.py
@@ -0,0 +1,29 @@
+"""Custom Gunicorn logger.
+
+Overrides the default timestamp atom `%(t)s` in access logs from Gunicorn's
+default format `[09/Aug/2026:17:42:48 +0000]` to an ISO-like local-time format
+`[2026-08-09 20:41:00 +0300]`, honoring the TZ environment variable.
+"""
+
+import os
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
+
+from gunicorn.glogging import Logger
+
+TIMEZONE = os.environ.get("TZ", "UTC")
+
+
+class CustomLogger(Logger):
+ """Gunicorn logger that emits timezone-aware ISO timestamps."""
+
+ def atoms(self, resp, req, environ, request_time):
+ """Build the log atoms, overriding the time atom with a custom format."""
+ atoms = super().atoms(resp, req, environ, request_time)
+ try:
+ now = datetime.now(ZoneInfo(TIMEZONE))
+ except Exception:
+ now = datetime.now(timezone.utc)
+ # [09/Aug/2026:17:42:48 +0000] -> [2026-08-09 20:41:00 +0300]
+ atoms["t"] = f"[{now.strftime('%Y-%m-%d %H:%M:%S %z')}]"
+ return atoms
\ No newline at end of file
diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py
index b8e0ab4..de1e6b7 100644
--- a/backend/tests/test_routes.py
+++ b/backend/tests/test_routes.py
@@ -82,6 +82,23 @@ class TestAuthRoutes:
data = response.get_json()
assert "already exists" in data["error"].lower()
+ @pytest.mark.auth
+ def test_register_disabled(self, app, client, monkeypatch):
+ """Test registration returns 404 when disabled via config"""
+ monkeypatch.setitem(app.config, "REGISTRATION_ENABLED", False)
+ response = client.post(
+ "/api/auth/register",
+ json={
+ "email": "disabled@example.com",
+ "password": "password123",
+ "confirm_password": "password123",
+ },
+ )
+
+ assert response.status_code == 404
+ data = response.get_json()
+ assert data["error"] == "Not found"
+
@pytest.mark.auth
def test_login_success(self, client, regular_user):
"""Test successful login"""
diff --git a/backend/wsgi.py b/backend/wsgi.py
index 489fcd0..99b105a 100644
--- a/backend/wsgi.py
+++ b/backend/wsgi.py
@@ -1,8 +1,11 @@
import os
from app import create_app
+from werkzeug.middleware.proxy_fix import ProxyFix
env = os.environ.get('FLASK_ENV', 'dev')
app = create_app(env)
+app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
+
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 3c19411..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,191 +0,0 @@
-version: '3.8'
-
-services:
- backend:
- build:
- context: ./backend
- dockerfile: Dockerfile
- container_name: crafting-shop-backend
- ports:
- - "5000:5000"
- environment:
- - FLASK_ENV=${FLASK_ENV:-prod}
- - SECRET_KEY=${SECRET_KEY}
- - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- depends_on:
- - postgres
- - redis
- networks:
- - crafting-shop-network
- volumes:
- - backend-data:/app/instance
- restart: unless-stopped
-
- frontend:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- container_name: crafting-shop-frontend
- ports:
- - "80:80"
- depends_on:
- - backend
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
- postgres:
- image: postgres:15-alpine
- container_name: crafting-shop-postgres
- environment:
- - POSTGRES_USER=${POSTGRES_USER:-crafting}
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- - POSTGRES_DB=${POSTGRES_DB:-crafting_shop}
- volumes:
- - postgres-data:/var/lib/postgresql/data
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
- redis:
- image: redis:7-alpine
- container_name: crafting-shop-redis
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
- minio:
- image: minio/minio:latest
- container_name: crafting-shop-minio
- command: server /data --console-address ":9001"
- ports:
- - "9000:9000"
- - "9001:9001"
- environment:
- - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- volumes:
- - minio-data:/data
- networks:
- - crafting-shop-network
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
- interval: 30s
- timeout: 20s
- retries: 3
-
- celery_worker:
- build:
- context: ./backend
- dockerfile: Dockerfile
- container_name: crafting-shop-celery-worker
- command: celery -A celery_worker worker --loglevel=info --concurrency=4
- environment:
- - FLASK_ENV=${FLASK_ENV:-prod}
- - SECRET_KEY=${SECRET_KEY}
- - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- depends_on:
- - redis
- - postgres
- - backend
- networks:
- - crafting-shop-network
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "celery", "-A", "celery_worker", "inspect", "ping", "-d", "celery@$$HOSTNAME"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 40s
-
- celery_beat:
- build:
- context: ./backend
- dockerfile: Dockerfile
- container_name: crafting-shop-celery-beat
- command: celery -A celery_worker beat --loglevel=info
- environment:
- - FLASK_ENV=${FLASK_ENV:-prod}
- - SECRET_KEY=${SECRET_KEY}
- - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- depends_on:
- - redis
- - postgres
- - backend
- networks:
- - crafting-shop-network
- restart: unless-stopped
- volumes:
- - celery-beat-data:/app/celerybeat
-
- flower:
- build:
- context: ./backend
- dockerfile: Dockerfile
- container_name: crafting-shop-flower
- command: celery -A celery_worker flower --port=5555
- ports:
- - "5555:5555"
- environment:
- - FLASK_ENV=${FLASK_ENV:-prod}
- - SECRET_KEY=${SECRET_KEY}
- - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- depends_on:
- - redis
- - celery_worker
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
- prometheus:
- image: prom/prometheus:latest
- container_name: crafting-shop-prometheus
- ports:
- - "9090:9090"
- volumes:
- - ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- - prometheus-data:/prometheus
- command:
- - '--config.file=/etc/prometheus/prometheus.yml'
- - '--storage.tsdb.path=/prometheus'
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
- grafana:
- image: grafana/grafana:latest
- container_name: crafting-shop-grafana
- ports:
- - "3001:3000"
- environment:
- - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
- volumes:
- - grafana-data:/var/lib/grafana
- networks:
- - crafting-shop-network
- restart: unless-stopped
-
-volumes:
- postgres-data:
- redis-data:
- prometheus-data:
- grafana-data:
- backend-data:
- celery-beat-data:
- minio-data:
-
-networks:
- crafting-shop-network:
- driver: bridge
\ No newline at end of file
diff --git a/docker/Dockerfile b/docker/Dockerfile
index fa2d5fb..047ab7c 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -3,6 +3,10 @@
# ---------------------------------------------------------
FROM node:18-alpine AS frontend-build
+# Build-time configurable: pass --build-arg REGISTRATION_ENABLED=false to disable registration
+ARG REGISTRATION_ENABLED=true
+ENV VITE_REGISTRATION_ENABLED=$REGISTRATION_ENABLED
+
WORKDIR /app/frontend
# Copy package files first for better caching
@@ -20,6 +24,10 @@ FROM python:3.11-slim
WORKDIR /app
+# Build-time configurable for the backend: pass --build-arg REGISTRATION_ENABLED=false to disable registration
+ARG REGISTRATION_ENABLED=true
+ENV REGISTRATION_ENABLED=$REGISTRATION_ENABLED
+
# Install system dependencies (if needed for python packages)
# RUN apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/*
@@ -41,10 +49,13 @@ USER appuser
# Expose port
EXPOSE 8000
-# Run with Gunicorn (Production WSGI Server)
-# --bind 0.0.0.0 makes it accessible outside the container
-# --access-logfile - sends access logs to stdout
-# --error-logfile - sends error logs to stderr
-# --capture-output ensures all output is captured
-# --log-level info sets the logging level
-CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "wsgi:app"]
+# # Run with Gunicorn (Production WSGI Server)
+# # --bind 0.0.0.0 makes it accessible outside the container
+# # --access-logfile - sends access logs to stdout
+# # --error-logfile - sends error logs to stderr
+# # --capture-output ensures all output is captured
+# # --log-level info sets the logging level
+# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--access-logformat", "%({x-forwarded-for}i)s - - [%(t)s] \"%(r)s\" %(s)s %(b)s \"%(f)s\" \"%(a)s\"", "--error-logfile", "-", "--capture-output", "--log-level", "info", "wsgi:app"]
+
+ENV TZ=Africa/Nairobi
+CMD ["gunicorn", "-c", "../backend/gunicorn.conf.py", "wsgi:app"]
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 097ad2f..3b740d8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar';
import { Home } from './pages/Home';
import Login from './pages/Login';
import { Register } from './pages/Register';
+import { REGISTRATION_ENABLED } from './config/registration';
import Profile from './pages/Profile';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Boards } from './pages/Boards';
@@ -59,7 +60,10 @@ const App = () => {
/>
} />
} />
- } />
+ : }
+ />
Login
-
- Register
-
+ {REGISTRATION_ENABLED && (
+
+ Register
+
+ )}
>
)}
@@ -136,13 +139,15 @@ export function Navbar() {
>
Login
- setMobileMenuOpen(false)}
- >
- Register
-
+ {REGISTRATION_ENABLED && (
+ setMobileMenuOpen(false)}
+ >
+ Register
+
+ )}
>
)}
diff --git a/frontend/src/config/registration.ts b/frontend/src/config/registration.ts
new file mode 100644
index 0000000..96a8c0c
--- /dev/null
+++ b/frontend/src/config/registration.ts
@@ -0,0 +1,5 @@
+// Inlined at build time by Vite via import.meta.env.
+// Unset (local dev / no build arg) → registration is enabled (backward compatible).
+// Set to "false" via docker build --build-arg REGISTRATION_ENABLED=false → registration disabled.
+export const REGISTRATION_ENABLED =
+ import.meta.env.VITE_REGISTRATION_ENABLED !== 'false';
\ No newline at end of file
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index d87dc44..347ca48 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
+import { REGISTRATION_ENABLED } from '../config/registration';
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email address'),
@@ -81,12 +82,14 @@ export default function Login() {
-
- Don‘t have an account?
-
- Register
-
-
+ {REGISTRATION_ENABLED && (
+
+ Don‘t have an account?
+
+ Register
+
+
+ )}
);
}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..151aa68
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
\ No newline at end of file