18 Commits

Author SHA1 Message Date
44ffae7f99 Merge pull request 'fix: auto-set Wagtail site hostname on startup' (#16) from fix/prod-site-hostname into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 2m30s
2026-02-28 22:08:29 +00:00
codex_a
c9dab3e93b fix: use correct Host header in deploy health check
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 44s
CI / ci (pull_request) Successful in 1m2s
ALLOWED_HOSTS doesn't include localhost, so curl with default Host
header always gets a 400 and the health check fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:55:58 +00:00
codex_a
349f1db721 fix: auto-set Wagtail site hostname from ALLOWED_HOSTS on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:54:27 +00:00
de56b564c5 Merge pull request 'feat: production deploy pipeline' (#15) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 1s
2026-02-28 21:50:04 +00:00
codex_a
833ff378ea fix: use entrypoint script for prod container startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
YAML folded block scalar (>) was preserving newlines for more-indented
continuation lines, so gunicorn received no arguments and defaulted to
binding on 127.0.0.1:8000. Replace with an explicit entrypoint script
so all args are passed correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:47:02 +00:00
codex_a
754b0ca5f6 fix: set build context to app/ in prod compose
The compose file lives in /srv/sum/nohype/ while the code is in
/srv/sum/nohype/app/ so the Dockerfile is at app/Dockerfile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:42:06 +00:00
03fcbdb5ad Merge pull request 'feat: production deploy pipeline' (#14) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 5s
Reviewed-on: #14
2026-02-28 21:40:03 +00:00
codex_a
0cbac68ec1 feat: add production deploy pipeline and fix dev CSS
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
Dev:
- Add tailwind install + build to docker-compose startup so CSS is built
  inside the container — not dependent on local filesystem

Production (docker-compose.prod.yml):
- Gunicorn on 127.0.0.1:8001, bind-mounted static/media to host paths
  so Caddy can serve them directly
- Runs migrate, tailwind build, collectstatic on startup

Settings (production.py):
- Disable SECURE_SSL_REDIRECT (Caddy handles redirects; Django would loop)
- Add CSRF_TRUSTED_ORIGINS for nohypeai.net

CI (.gitea/workflows/ci.yml):
- Add push-to-main trigger
- Add deploy job: SSHes to lintel-prod-01 as deploy, runs deploy/deploy.sh

Server config (deploy/):
- deploy/caddy/nohype.caddy — Caddy site config for nohypeai.net
- deploy/sum-nohype.service — systemd unit for the compose stack
- deploy/deploy.sh — deploy script (pull, build, restart)

One-time manual steps required on lintel-prod-01 (need root):
  sudo cp deploy/sum-nohype.service /etc/systemd/system/
  sudo cp deploy/caddy/nohype.caddy /etc/caddy/sites-enabled/
  sudo systemctl daemon-reload && sudo systemctl enable sum-nohype
  sudo systemctl reload caddy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:34:13 +00:00
4c27cfe1dd Merge pull request 'fix: make docker compose up work out of the box' (#13) from fix/dev-setup into main
Reviewed-on: #13
2026-02-28 21:00:10 +00:00
codex_a
a598727888 fix: fix all dev static/media serving issues
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
- Remove WhiteNoise from MIDDLEWARE in development: it intercepts /static/
  requests and serves from STATIC_ROOT, which is empty without collectstatic.
  Django's runserver serves static files natively with DEBUG=True.
- Switch to StaticFilesStorage in dev: no manifest required.
- Add media URL pattern in DEBUG mode: runserver does not serve MEDIA_ROOT
  automatically, so uploaded images were 404ing in local dev.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:57:54 +00:00
36eb0f1dd2 Merge pull request 'fix: use plain StaticFilesStorage in dev settings' (#10) from fix/seed-default-site into main
Reviewed-on: #10
2026-02-28 20:48:44 +00:00
codex_a
f950e3cd5e fix: use plain StaticFilesStorage in dev settings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m24s
CompressedManifestStaticFilesStorage requires collectstatic to generate a
manifest before it can serve anything. Dev containers never run collectstatic
so every static asset 404s. Override to StaticFilesStorage in dev so Django
serves files directly from STATICFILES_DIRS and app static directories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:46:23 +00:00
311ad80320 Merge pull request 'fix: update ALL site records in seed, not just is_default_site' (#9) from fix/seed-default-site into main
Reviewed-on: #9
2026-02-28 20:42:24 +00:00
codex_a
08e003e165 fix: update ALL site records in seed, not just is_default_site
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
Wagtail's initial migration creates a localhost:80 site. Wagtail matches
incoming requests by hostname before ever checking is_default_site, so
updating only the is_default_site record left localhost:80 still pointing
at the Welcome page. Fix by updating root_page on every site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:40:20 +00:00
076aaa0b9e Merge pull request 'fix: update existing default site in seed command' (#8) from fix/seed-default-site into main
Reviewed-on: #8
2026-02-28 20:35:15 +00:00
codex_a
7b2ad4cfe5 fix: remove server-specific playwright volume from dev compose, auto-seed on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m23s
- /opt/playwright-tools/browsers only exists on agent-workspace; mounting it
  in docker-compose.yml breaks local dev for everyone else
- seed_e2e_content is idempotent so safe to run on every startup; removes the
  manual step that nobody knew about

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:33:40 +00:00
codex_a
56e53478ea fix: update existing default site in seed command instead of hardcoding 127.0.0.1
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m10s
CI / ci (pull_request) Successful in 1m22s
Wagtail initialises the default site with hostname 'localhost'. The previous
get_or_create on '127.0.0.1' left the localhost site intact (still pointing
to the Welcome page), so browsers got the wrong root page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:31:20 +00:00
96f9eca19d Merge pull request 'feat: comprehensive Playwright E2E test suite' (#7) from tests/e2e into main
Reviewed-on: #7
2026-02-28 20:25:29 +00:00
11 changed files with 190 additions and 17 deletions

View File

@@ -2,6 +2,9 @@ name: CI
on:
pull_request:
push:
branches:
- main
schedule:
- cron: "0 2 * * *"
@@ -188,3 +191,15 @@ jobs:
- name: Remove CI image
if: always()
run: docker image rm -f "$CI_IMAGE" || true
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to lintel-prod-01
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /srv/sum/nohype/app/deploy/deploy.sh

View File

@@ -116,20 +116,16 @@ class Command(BaseCommand):
legal_index.add_child(instance=privacy)
privacy.save_revision().publish()
site, _ = Site.objects.get_or_create(
hostname="127.0.0.1",
port=8000,
defaults={
"root_page": home,
"is_default_site": True,
"site_name": "No Hype AI",
},
)
site.root_page = home
# Point every existing Site at the real home page and mark exactly one
# as the default. Wagtail's initial migration creates a localhost:80
# site that matches incoming requests by hostname before the
# is_default_site fallback is ever reached, so we must update *all*
# sites, not just the is_default_site one.
Site.objects.all().update(root_page=home, site_name="No Hype AI", is_default_site=False)
site = Site.objects.first()
if site is None:
site = Site(hostname="localhost", port=80)
site.is_default_site = True
site.site_name = "No Hype AI"
site.save()
# Remove any other conflicting default-site entries left by test fixtures
Site.objects.exclude(pk=site.pk).filter(is_default_site=True).update(is_default_site=False)
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))

View File

@@ -4,6 +4,13 @@ DEBUG = True
INTERNAL_IPS = ["127.0.0.1"]
# Drop WhiteNoise in dev — it serves from STATIC_ROOT which is empty without
# collectstatic, so it 404s every asset. Django's runserver serves static and
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
# media URL pattern in urls.py).
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
try:
import debug_toolbar # noqa: F401

View File

@@ -2,8 +2,16 @@ from .base import * # noqa
DEBUG = False
# Behind Caddy: trust the forwarded proto header so Django knows it's HTTPS.
# SECURE_SSL_REDIRECT is intentionally off — Caddy handles HTTPS redirects
# before the request reaches Django; enabling it here causes redirect loops.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_SSL_REDIRECT = True
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = [
"https://nohypeai.net",
"https://www.nohypeai.net",
]

View File

@@ -1,3 +1,5 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
@@ -21,3 +23,6 @@ urlpatterns = [
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

23
deploy/caddy/nohype.caddy Normal file
View File

@@ -0,0 +1,23 @@
nohypeai.net, www.nohypeai.net {
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()"
X-Forwarded-Proto https
}
handle_path /static/* {
root * /srv/sum/nohype/static
file_server
}
handle_path /media/* {
root * /srv/sum/nohype/media
file_server
}
reverse_proxy localhost:8001
}

34
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Deploy script for No Hype AI — runs on lintel-prod-01 as deploy user.
# Called by CI after a successful push to main.
set -euo pipefail
SITE_DIR=/srv/sum/nohype
APP_DIR=${SITE_DIR}/app
echo "==> Pulling latest code"
git -C "${APP_DIR}" pull origin main
echo "==> Updating compose file"
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
echo "==> Ensuring static/media directories exist"
mkdir -p "${SITE_DIR}/static" "${SITE_DIR}/media"
echo "==> Building image"
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" build --no-cache
echo "==> Restarting service"
sudo systemctl restart sum-nohype
echo "==> Waiting for health check"
for i in $(seq 1 30); do
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
echo "==> Site is up"
exit 0
fi
sleep 3
done
echo "ERROR: site did not come up after 90s" >&2
sudo journalctl -u sum-nohype --no-pager -n 50
exit 1

22
deploy/entrypoint.prod.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
python manage.py tailwind install --no-input
python manage.py tailwind build
python manage.py migrate --noinput
python manage.py collectstatic --noinput
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
python manage.py shell -c "
from wagtail.models import Site
import os
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
"
exec gunicorn config.wsgi:application \
--workers 3 \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile - \
--capture-output

26
deploy/sum-nohype.service Normal file
View File

@@ -0,0 +1,26 @@
[Unit]
Description=No Hype AI (Docker Compose)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
User=deploy
Group=www-data
WorkingDirectory=/srv/sum/nohype
ExecStartPre=docker compose -f docker-compose.prod.yml pull --ignore-pull-failures
ExecStart=docker compose -f docker-compose.prod.yml up --build
ExecStop=docker compose -f docker-compose.prod.yml down
Restart=on-failure
RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sum-nohype
[Install]
WantedBy=multi-user.target

36
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
web:
build: app
working_dir: /app
command: /app/deploy/entrypoint.prod.sh
env_file: .env
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
volumes:
- /srv/sum/nohype/static:/app/staticfiles
- /srv/sum/nohype/media:/app/media
ports:
- "127.0.0.1:8001:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
environment:
POSTGRES_DB: nohype
POSTGRES_USER: nohype
volumes:
- nohype_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
nohype_pg:

View File

@@ -3,11 +3,13 @@ services:
build: .
working_dir: /app
command: >
sh -c "python manage.py migrate --noinput &&
sh -c "python manage.py tailwind install --no-input &&
python manage.py tailwind build &&
python manage.py migrate --noinput &&
python manage.py seed_e2e_content &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/app
- /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro
ports:
- "8035:8000"
environment:
@@ -22,7 +24,6 @@ services:
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL: hello@nohypeai.com
NEWSLETTER_PROVIDER: buttondown
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-tools/browsers
depends_on:
db:
condition: service_healthy