diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..919fa26 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +out +.git +.claude +content/*.mp4 +content/*.pdf +*.md +!README.md diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..ed9bd28 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,25 @@ +name: Deploy + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + container: + volumes: + - /opt:/opt + + steps: + - name: Deploy + run: | + cd /opt/ttvfx_web + + git pull + + docker compose up -d --build + + docker image prune -f diff --git a/DEPLOY.md b/DEPLOY.md index 054a341..b21607b 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,86 +1,139 @@ # Deploying twotalesvfx.com -The site is a fully static export, so there is no Node process to run in production. +Same setup as the other apps (vfxreview, xbid): -## 1. Build +``` +git push main ──► Gitea Actions runner ──► cd /opt/ttvfx_web && git pull + docker compose up -d --build + │ + Cloudflare ──► Nginx Proxy Manager ──► ttvfx-web:80 (nginx, static files) + (npm_default network) +``` + +Files involved: + +| File | What it does | +|------|--------------| +| `Dockerfile` | Builds the static export with Node 20, then serves `out/` from `nginx:alpine` | +| `docker/nginx.conf` | nginx config inside the container (routes, 404 page, caching, relative redirects) | +| `docker-compose.yml` | One service, container name `ttvfx-web`, joined to the external `npm_default` network, no host ports | +| `.gitea/workflows/deploy.yml` | On push to `main`: pull in `/opt/ttvfx_web` and rebuild | + +There is no `.env` and no database. The site is fully static. + +--- + +## One-time setup on the server + +### 1. SSH in ```bash -npm ci -npm run build # outputs ./out +ssh @178.104.56.62 # git.twotalesvfx.com's IP; use your usual host if different ``` -Or build on the server straight from git: +### 2. Check how the existing apps are set up, and copy that ```bash -git clone https://git.twotalesvfx.com/pinto/ttvfx.git /opt/ttvfx -cd /opt/ttvfx && npm ci && npm run build +ls -la /opt # note the owner of /opt/xbid and /opt/vfxreview +git -C /opt/xbid remote -v # note HOW it authenticates (https+token, ssh, ...) +docker network ls | grep npm_default ``` -## 2. DNS (Cloudflare) +`npm_default` must exist. It's the Nginx Proxy Manager network. -`twotalesvfx.com` is already on Cloudflare (proxied). Point the apex and `www` -at the VPS and leave the existing `review` and `git` records alone: +### 3. Clone into /opt/ttvfx_web -| Type | Name | Value | Proxy | -|------|-------|--------------|---------| -| A | `@` | `` | Proxied | -| A | `www` | `` | Proxied | - -At the time of writing the apex returned Cloudflare **522** (origin unreachable), -so the current `@` record needs updating. - -SSL/TLS mode: **Full (strict)**, using either certbot (below) or a Cloudflare -Origin Certificate installed in nginx. - -## 3. Upload - -If building locally: +Use the same remote style as `/opt/xbid` so the runner's `git pull` can authenticate. +For example, if xbid uses an https remote: ```bash -rsync -avz --delete out/ user@your-vps:/var/www/twotalesvfx.com/ +sudo git clone https://git.twotalesvfx.com/pinto/ttvfx.git /opt/ttvfx_web ``` -If building on the server, copy or symlink `/opt/ttvfx/out` to `/var/www/twotalesvfx.com`. - -## 4. nginx - -`/etc/nginx/sites-available/twotalesvfx.com`: - -```nginx -server { - listen 80; - server_name twotalesvfx.com www.twotalesvfx.com; - root /var/www/twotalesvfx.com; - - # www -> apex - if ($host = www.twotalesvfx.com) { return 301 https://twotalesvfx.com$request_uri; } - - location / { - try_files $uri $uri/ $uri.html =404; - } - - # Hashed build assets can be cached forever - location /_next/static/ { - add_header Cache-Control "public, max-age=31536000, immutable"; - } - - # Video: allow range requests to be cached - location ~* \.mp4$ { - add_header Cache-Control "public, max-age=604800"; - } - - error_page 404 /404.html; - - gzip on; - gzip_types text/css application/javascript image/svg+xml application/json; -} -``` +Then match the ownership of the other app folders (replace `` with what `ls -la /opt` showed): ```bash -sudo ln -s /etc/nginx/sites-available/twotalesvfx.com /etc/nginx/sites-enabled/ -sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d twotalesvfx.com -d www.twotalesvfx.com # HTTPS via Let's Encrypt +sudo chown -R : /opt/ttvfx_web ``` -(For Apache, set `DocumentRoot` to the same folder plus `ErrorDocument 404 /404.html`. -Because the build uses `trailingSlash`, every page is a folder with its own `index.html`.) +If xbid's remote URL contains a token or uses SSH, clone this one the same way +(e.g. `git clone git@git.twotalesvfx.com:pinto/ttvfx.git /opt/ttvfx_web`). + +### 4. First build by hand + +```bash +cd /opt/ttvfx_web +docker compose up -d --build +docker ps --filter name=ttvfx-web # should be "Up" +``` + +The first build takes a minute or two (npm install + Next build). Later builds reuse the cache. + +Check that it serves from inside the proxy network: + +```bash +docker run --rm --network npm_default curlimages/curl -sI http://ttvfx-web/ +# -> HTTP/1.1 200 OK +``` + +### 5. Nginx Proxy Manager: add the proxy host + +In the NPM web UI → **Hosts → Proxy Hosts → Add Proxy Host**: + +- **Domain names:** `twotalesvfx.com`, `www.twotalesvfx.com` +- **Scheme:** `http` +- **Forward hostname / IP:** `ttvfx-web` +- **Forward port:** `80` +- Tick **Block common exploits**. Websockets aren't needed. +- **SSL tab:** request a new Let's Encrypt certificate, then tick **Force SSL** and **HTTP/2**. + - Cloudflare is proxying the domain. If the HTTP challenge fails, either grey-cloud the two + records for a minute while the cert is issued, or use **DNS challenge → Cloudflare** + with an API token. Use whichever you did for `review.` / `xbid.`. + +### 6. Cloudflare DNS + +The apex currently returns **522** (Cloudflare can't reach the origin). Point it at this server: + +| Type | Name | Value | Proxy | +|------|-------|------------------|---------| +| A | `@` | `178.104.56.62` | Proxied | +| A | `www` | `178.104.56.62` | Proxied | + +(Confirm the server's IP with `curl -4 ifconfig.me` on the box.) Leave `review`, `git`, `xbid` etc. alone. +Set **SSL/TLS → Full (strict)**, the same as the other subdomains. + +### 7. Turn on auto-deploy + +The workflow is already in the repo (`.gitea/workflows/deploy.yml`). In Gitea, open +**pinto/ttvfx → Settings → Repository → Enable Actions** if it isn't enabled already +(it's on for the other repos). The existing runner picks the job up. It uses the same +`ubuntu-latest` label and `/opt:/opt` mount as xbid. + +Test it: push any commit to `main` and watch **Actions** in the repo. The job should run +`git pull` and `docker compose up -d --build`, then the site updates. + +--- + +## Day to day + +```bash +git push # from your machine. That's it, the runner deploys. +``` + +Manual redeploy or logs on the server: + +```bash +cd /opt/ttvfx_web && git pull && docker compose up -d --build +docker logs -f ttvfx-web +``` + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Action fails on `git pull` with *dubious ownership* | `git config --global --add safe.directory /opt/ttvfx_web` (run it however you did for xbid), or fix ownership to match `/opt/xbid` | +| Action fails on `git pull` with *authentication* | The clone's remote URL needs the same credentials as `/opt/xbid` (`git -C /opt/xbid remote -v`) | +| `network npm_default declared as external, but could not be found` | NPM's network has a different name on this box. Check `docker network ls` and update `docker-compose.yml` | +| NPM shows **502 Bad Gateway** | Container not running (`docker ps`) or wrong forward host. It must be `ttvfx-web`, port `80` | +| Cloudflare **522** | DNS `@`/`www` still point at the wrong IP, or ports 80/443 are firewalled | +| Old content after deploy | Purge Cloudflare cache (Caching → Purge Everything). HTML isn't cached long, but video and images are cached for 7 days | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e5a9d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# ---- Builder: static export -> /app/out ---- +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ---- Runner: plain nginx serving the static files ---- +FROM nginx:1.27-alpine AS runner + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/out /usr/share/nginx/html + +EXPOSE 80 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b14e638 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + web: + build: . + container_name: ttvfx-web + + restart: unless-stopped + + networks: + - npm_default + +networks: + npm_default: + external: true diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..2e350bc --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,30 @@ +# Serves the Next.js static export. TLS and the public hostname are handled +# by Nginx Proxy Manager in front of this container. +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + + # Behind a TLS proxy: keep redirects relative so they stay on https + absolute_redirect off; + + # Pages are exported as folder/index.html (trailingSlash: true) + location / { + try_files $uri $uri/ $uri.html =404; + } + + # Content-hashed build assets never change + location /_next/static/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Video and images: cache for a week (nginx handles range requests) + location ~* \.(mp4|jpg|jpeg|png|webp|svg)$ { + add_header Cache-Control "public, max-age=604800"; + } + + error_page 404 /404.html; + + gzip on; + gzip_types text/css application/javascript image/svg+xml application/json text/plain application/xml; +}