diff --git a/.woodpecker.yml b/.woodpecker.yml
index 6826f54..526d036 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -3,10 +3,13 @@ when:
event: [push, manual]
steps:
- deploy:
- image: alpine
+ build-and-deploy:
+ image: docker:cli
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - /opt/services:/opt/services
commands:
- - echo "Deploying to /webroot..."
- - apk add --no-cache rsync
- - rsync -av --delete --exclude='.git' --exclude='.woodpecker.yml' ./ /webroot/
- - echo "Deploy complete"
+ - mkdir -p /opt/services/app
+ - cp -r . /opt/services/app/
+ - docker build -t k-boris-website /opt/services/app/
+ - docker compose -f /opt/services/docker-compose.yml up -d --no-deps django
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..52e6796
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.12-slim
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Build-time only — overridden at runtime via docker-compose env
+ARG DJANGO_SECRET_KEY=build-placeholder
+ENV DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY
+
+RUN python manage.py collectstatic --noinput
+
+EXPOSE 8080
+ENTRYPOINT ["./entrypoint.sh"]
diff --git a/core/__init__.py b/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/admin.py b/core/admin.py
new file mode 100644
index 0000000..28889cc
--- /dev/null
+++ b/core/admin.py
@@ -0,0 +1,10 @@
+from django.contrib import admin
+from .models import Visit
+
+
+@admin.register(Visit)
+class VisitAdmin(admin.ModelAdmin):
+ list_display = ['ip', 'timestamp']
+ list_filter = ['timestamp']
+ readonly_fields = ['ip', 'timestamp']
+ ordering = ['-timestamp']
diff --git a/core/apps.py b/core/apps.py
new file mode 100644
index 0000000..c5a27e7
--- /dev/null
+++ b/core/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+class CoreConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'core'
diff --git a/core/models.py b/core/models.py
new file mode 100644
index 0000000..7978b37
--- /dev/null
+++ b/core/models.py
@@ -0,0 +1,12 @@
+from django.db import models
+
+
+class Visit(models.Model):
+ ip = models.GenericIPAddressField()
+ timestamp = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-timestamp']
+
+ def __str__(self):
+ return f"{self.ip} @ {self.timestamp}"
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
new file mode 100644
index 0000000..0cfb43c
--- /dev/null
+++ b/core/templates/core/index.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+ k-boris.tech
+
+
+
+
+
+
k-boris.tech
+
Work in progress.
+
+
+
+
+
+
diff --git a/core/urls.py b/core/urls.py
new file mode 100644
index 0000000..6e21aeb
--- /dev/null
+++ b/core/urls.py
@@ -0,0 +1,6 @@
+from django.urls import path
+from . import views
+
+urlpatterns = [
+ path('', views.index, name='index'),
+]
diff --git a/core/views.py b/core/views.py
new file mode 100644
index 0000000..bb08f88
--- /dev/null
+++ b/core/views.py
@@ -0,0 +1,61 @@
+from datetime import timedelta
+from django.utils import timezone
+from django.shortcuts import render
+from .models import Visit
+
+
+def _get_server_info():
+ try:
+ with open('/proc/uptime') as f:
+ total_seconds = int(float(f.read().split()[0]))
+ days, rem = divmod(total_seconds, 86400)
+ hours, mins = divmod(rem, 3600)
+ uptime = f"{days}d {hours}h {mins // 60}m"
+ except Exception:
+ uptime = 'N/A'
+
+ try:
+ with open('/proc/loadavg') as f:
+ parts = f.read().split()
+ load = f"{parts[0]} {parts[1]} {parts[2]}"
+ except Exception:
+ load = 'N/A'
+
+ try:
+ meminfo = {}
+ with open('/proc/meminfo') as f:
+ for line in f:
+ key, val = line.split(':')
+ meminfo[key.strip()] = int(val.split()[0])
+ mem_total = meminfo['MemTotal'] // 1024
+ mem_free = meminfo['MemAvailable'] // 1024
+ mem_used = mem_total - mem_free
+ except Exception:
+ mem_total = mem_free = mem_used = 0
+
+ return {
+ 'uptime': uptime,
+ 'load': load,
+ 'mem_total': mem_total,
+ 'mem_free': mem_free,
+ 'mem_used': mem_used,
+ }
+
+
+def index(request):
+ forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
+ ip = forwarded.split(',')[0].strip() if forwarded else request.META.get('REMOTE_ADDR', 'unknown')
+
+ Visit.objects.create(ip=ip)
+
+ now = timezone.now()
+ start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ context = {
+ 'visitor_ip': ip,
+ 'visits_today': Visit.objects.filter(timestamp__gte=start_of_day).count(),
+ 'visits_week': Visit.objects.filter(timestamp__gte=now - timedelta(days=7)).count(),
+ 'visits_month': Visit.objects.filter(timestamp__gte=now - timedelta(days=30)).count(),
+ **_get_server_info(),
+ }
+ return render(request, 'core/index.html', context)
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 0000000..f7f9c0d
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+python manage.py migrate --noinput
+exec gunicorn kboris.wsgi:application \
+ --bind 0.0.0.0:8080 \
+ --workers 2 \
+ --access-logfile - \
+ --error-logfile -
diff --git a/index.html b/index.html
deleted file mode 100644
index d704ed9..0000000
--- a/index.html
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
- k-boris.tech
-
-
-
-
-
k-boris.tech
-
Coming soon.
-
-
-
diff --git a/kboris/__init__.py b/kboris/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kboris/settings.py b/kboris/settings.py
new file mode 100644
index 0000000..0adb111
--- /dev/null
+++ b/kboris/settings.py
@@ -0,0 +1,96 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key')
+DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
+
+ALLOWED_HOSTS = [
+ 'k-boris.tech',
+ 'www.k-boris.tech',
+ 'admin.k-boris.tech',
+ 'localhost',
+ '127.0.0.1',
+]
+
+CSRF_TRUSTED_ORIGINS = [
+ 'https://k-boris.tech',
+ 'https://www.k-boris.tech',
+ 'https://admin.k-boris.tech',
+]
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'whitenoise.runserver_nostatic',
+ 'django.contrib.staticfiles',
+ 'core',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'whitenoise.middleware.WhiteNoiseMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'kboris.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'kboris.wsgi.application'
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': Path('/data/db.sqlite3'),
+ }
+}
+
+AUTH_PASSWORD_VALIDATORS = [
+ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
+]
+
+LANGUAGE_CODE = 'en-us'
+TIME_ZONE = 'UTC'
+USE_I18N = True
+USE_TZ = True
+
+STATIC_URL = '/static/'
+STATIC_ROOT = BASE_DIR / 'staticfiles'
+STORAGES = {
+ 'staticfiles': {
+ 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
+ },
+}
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+# Trust nginx reverse proxy headers
+USE_X_FORWARDED_HOST = True
+SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
diff --git a/kboris/urls.py b/kboris/urls.py
new file mode 100644
index 0000000..ac12108
--- /dev/null
+++ b/kboris/urls.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+from django.urls import path, include
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path('', include('core.urls')),
+]
diff --git a/kboris/wsgi.py b/kboris/wsgi.py
new file mode 100644
index 0000000..131081c
--- /dev/null
+++ b/kboris/wsgi.py
@@ -0,0 +1,5 @@
+import os
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kboris.settings')
+application = get_wsgi_application()
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..d387366
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+import os
+import sys
+
+def main():
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kboris.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+if __name__ == '__main__':
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a3361c2
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+Django>=5.0,<6.0
+gunicorn>=21.0
+whitenoise>=6.6