diff --git a/infra/ansible/roles/veza_app/tasks/config_static.yml b/infra/ansible/roles/veza_app/tasks/config_static.yml index d40424eec..13192c757 100644 --- a/infra/ansible/roles/veza_app/tasks/config_static.yml +++ b/infra/ansible/roles/veza_app/tasks/config_static.yml @@ -1,8 +1,34 @@ -# Stub — filled by the web-component commit. -# Will: render veza_app_nginx_template → veza_app_nginx_site, swap the -# `current` symlink to the new SHA's release dir, nginx -t validate, -# systemctl reload nginx. +# Render nginx config and reload it. Used for kind=static (web). +# The dist/ tarball was already extracted under /var/www/veza-web/ +# by artifact.yml ; the only delta this task makes between deploys is +# the symlink swap + nginx reload (the freshly-launched container +# always reaches this task in a clean state, so the reload is mostly +# defensive — first-run config render needs it). --- -- name: Static component config (placeholder) - ansible.builtin.debug: - msg: "TODO: render nginx site at {{ veza_app_nginx_site }} pointing at {{ veza_app_release_dir }}" +- name: Disable the default nginx site so it never shadows ours + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + tags: [veza_app, config] + +- name: Render veza-web nginx site + ansible.builtin.template: + src: "{{ veza_app_nginx_template }}" + dest: "{{ veza_app_nginx_site }}" + owner: root + group: root + mode: "0644" + validate: "nginx -t -c /etc/nginx/nginx.conf -q" + notify: "veza-app reload-nginx" + tags: [veza_app, config] + +- name: Flush handlers so nginx reloads before the probe + ansible.builtin.meta: flush_handlers + tags: [veza_app, config] + +- name: Enable + start nginx + ansible.builtin.systemd: + name: nginx + state: started + enabled: true + tags: [veza_app, service] diff --git a/infra/ansible/roles/veza_app/templates/veza-web-nginx.conf.j2 b/infra/ansible/roles/veza_app/templates/veza-web-nginx.conf.j2 new file mode 100644 index 000000000..69e456033 --- /dev/null +++ b/infra/ansible/roles/veza_app/templates/veza-web-nginx.conf.j2 @@ -0,0 +1,78 @@ +# Managed by Ansible — do not edit by hand. +# veza_app role, templates/veza-web-nginx.conf.j2. +# Released SHA: {{ veza_release_sha }} ; color: {{ veza_target_color }} + +# We are upstream of the global HAProxy — no TLS here, no rate limit, +# no auth. Just serve dist/ with strong cache headers + the SPA +# fallback (try_files ... /index.html) so client-side routes resolve +# on hard reload. + +server { + listen {{ veza_web_port }} default_server; + listen [::]:{{ veza_web_port }} default_server; + server_name _; + + root {{ veza_app_current_link }}; + index index.html; + + # Health endpoint HAProxy checks. Returns 200 + a one-byte body so + # we never accidentally rely on the 200 having content. Path /health + # would conflict with backend's /health, but `/` is what veza_web + # is checked on per veza_healthcheck_paths.web — kept here as a + # simple internal probe target if ops wants to bypass the SPA. + location = /__nginx_alive { + access_log off; + return 200 "ok\n"; + default_type text/plain; + } + + # Long-cache the immutable hashed bundles (Vite emits content- + # hashed filenames in assets/). 1 year + immutable. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Service worker — must NEVER be long-cached or PWA updates + # stall on stale clients. + location = /sw.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; + } + location = /workbox-config.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; + } + + # Manifest, robots, favicon — short cache so SEO/PWA edits land + # within ~minute of a deploy. + location ~* \.(webmanifest|json|xml|txt|ico)$ { + expires 5m; + add_header Cache-Control "public, max-age=300"; + try_files $uri =404; + } + + # SPA fallback — every unknown route gets index.html so React + # Router resolves it. + location / { + try_files $uri $uri/ /index.html; + expires 5m; + add_header Cache-Control "public, max-age=300"; + # Security headers — defense in depth ; HAProxy strips/adds + # them upstream in prod. + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + } + + # Pre-compressed gzip assets if Vite emitted them + gzip_static on; + + # Errors lean on /index.html so a deep link reload doesn't show + # nginx's default page. + error_page 404 /index.html; + error_page 500 502 503 504 /index.html; +}