Compare commits
18 Commits
tests/e2e
...
fix/deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3e1ee1bf
|
||
|
|
c9dab3e93b
|
||
|
|
349f1db721
|
||
| de56b564c5 | |||
|
|
833ff378ea
|
||
|
|
754b0ca5f6
|
||
| 03fcbdb5ad | |||
|
|
0cbac68ec1
|
||
| 4c27cfe1dd | |||
|
|
a598727888
|
||
| 36eb0f1dd2 | |||
|
|
f950e3cd5e
|
||
| 311ad80320 | |||
|
|
08e003e165
|
||
| 076aaa0b9e | |||
|
|
7b2ad4cfe5
|
||
|
|
56e53478ea
|
||
| 96f9eca19d |
@@ -2,6 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
|
|
||||||
@@ -188,3 +191,15 @@ jobs:
|
|||||||
- name: Remove CI image
|
- name: Remove CI image
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image rm -f "$CI_IMAGE" || true
|
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
|
||||||
|
|||||||
@@ -116,20 +116,16 @@ class Command(BaseCommand):
|
|||||||
legal_index.add_child(instance=privacy)
|
legal_index.add_child(instance=privacy)
|
||||||
privacy.save_revision().publish()
|
privacy.save_revision().publish()
|
||||||
|
|
||||||
site, _ = Site.objects.get_or_create(
|
# Point every existing Site at the real home page and mark exactly one
|
||||||
hostname="127.0.0.1",
|
# as the default. Wagtail's initial migration creates a localhost:80
|
||||||
port=8000,
|
# site that matches incoming requests by hostname before the
|
||||||
defaults={
|
# is_default_site fallback is ever reached, so we must update *all*
|
||||||
"root_page": home,
|
# sites, not just the is_default_site one.
|
||||||
"is_default_site": True,
|
Site.objects.all().update(root_page=home, site_name="No Hype AI", is_default_site=False)
|
||||||
"site_name": "No Hype AI",
|
site = Site.objects.first()
|
||||||
},
|
if site is None:
|
||||||
)
|
site = Site(hostname="localhost", port=80)
|
||||||
site.root_page = home
|
|
||||||
site.is_default_site = True
|
site.is_default_site = True
|
||||||
site.site_name = "No Hype AI"
|
|
||||||
site.save()
|
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."))
|
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ DEBUG = True
|
|||||||
|
|
||||||
INTERNAL_IPS = ["127.0.0.1"]
|
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:
|
try:
|
||||||
import debug_toolbar # noqa: F401
|
import debug_toolbar # noqa: F401
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ from .base import * # noqa
|
|||||||
|
|
||||||
DEBUG = False
|
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")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = False
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
"https://nohypeai.net",
|
||||||
|
"https://www.nohypeai.net",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
@@ -21,3 +23,6 @@ urlpatterns = [
|
|||||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||||
path("", include(wagtail_urls)),
|
path("", include(wagtail_urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
23
deploy/caddy/nohype.caddy
Normal file
23
deploy/caddy/nohype.caddy
Normal 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
|
||||||
|
}
|
||||||
31
deploy/deploy.sh
Executable file
31
deploy/deploy.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/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 "==> Rebuilding and recreating web container"
|
||||||
|
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build --force-recreate web
|
||||||
|
|
||||||
|
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
|
||||||
|
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" logs --tail=50 web
|
||||||
|
exit 1
|
||||||
22
deploy/entrypoint.prod.sh
Executable file
22
deploy/entrypoint.prod.sh
Executable 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
26
deploy/sum-nohype.service
Normal 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=always
|
||||||
|
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
36
docker-compose.prod.yml
Normal 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:
|
||||||
@@ -3,11 +3,13 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: >
|
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"
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "8035:8000"
|
- "8035:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -22,7 +24,6 @@ services:
|
|||||||
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
||||||
NEWSLETTER_PROVIDER: buttondown
|
NEWSLETTER_PROVIDER: buttondown
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-tools/browsers
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Reference in New Issue
Block a user