Optimizing My Hugo Site: Performance, Security, and SEO
Table of Contents
After setting up the initial deployment pipeline, I ran the site through PageSpeed Insights, Google Search Console, and a few security scanners. The results surfaced a number of issues worth fixing. This post covers everything I changed — from redirect errors and performance to security headers and SEO.
Fixing Redirect Errors#
Google Search Console flagged a redirect error preventing pages from being indexed. The cause was a two-hop redirect chain triggered by URLs without trailing slashes:
- Googlebot requests
https://mysite.com/about - The Hugo container’s nginx issues a
301tohttp://mysite.com/about/— absolute, HTTP, because the container only speaks HTTP internally - The reverse proxy catches the HTTP request and issues another
301tohttps://mysite.com/about/
Adding proxy_redirect http:// https://; to the reverse proxy collapses this into a single redirect. The backend’s http:// location headers get rewritten to https:// before they reach the client:
1location / {
2 proxy_pass http://host.docker.internal:81;
3 proxy_set_header Host $host;
4 proxy_set_header X-Real-IP $remote_addr;
5 proxy_redirect http:// https://;
6}
Performance#
Gzip Compression#
The Hugo container serves uncompressed responses by default. Adding gzip at the reverse proxy level compresses HTML, CSS, and JavaScript before they reach the client:
1gzip on;
2gzip_types text/plain text/css application/javascript application/json image/svg+xml;
3gzip_min_length 1024;
4gzip_proxied any;
5gzip_vary on;
gzip_vary on adds a Vary: Accept-Encoding header so Cloudflare’s CDN caches compressed and uncompressed versions separately.
Long Cache TTLs for Static Assets#
Hugo fingerprints CSS and JavaScript files by content hash — the filename changes whenever the content changes. This makes them safe to cache indefinitely. A separate location block sets a one-year TTL:
1location ~* \.(css|js)$ {
2 proxy_pass http://host.docker.internal:81;
3 proxy_set_header Host $host;
4 proxy_set_header X-Real-IP $remote_addr;
5 proxy_redirect http:// https://;
6 expires 1y;
7 add_header Cache-Control "public, immutable";
8}
Deferring JavaScript#
The theme’s bundle.min.js was loaded in the footer without defer, keeping it on the critical render path. Overriding the theme’s footer.html partial and adding defer removes it:
1<script type="text/javascript" src="{{ $bundle.RelPermalink }}" defer></script>
Font Preloading#
The FiraCode font was being discovered late — only after the CSS was parsed — adding it to the critical path chain. Preloading it in <head> starts the download in parallel with the CSS:
1<link rel="preload" href="/fonts/FiraCode-Latin.woff2" as="font" type="font/woff2" crossorigin>
2<link rel="preload" href="/fonts/FiraCode-LatinExt.woff2" as="font" type="font/woff2" crossorigin>
This dropped the maximum critical path latency from 220ms to 177ms.
Hugo Configuration#
A few Hugo settings that improve the site without requiring any template changes:
1enableGitInfo = true
2
3[markup.highlight]
4 lineNos = true
5 lineNumbersInTable = false
6 noClasses = false
7
8[markup.goldmark.renderer]
9 unsafe = true
10
11[params]
12 showLastUpdated = true
13 Toc = true
14 readingTime = true
enableGitInfo pulls lastmod from git history for each file, keeping the sitemap accurate without manually updating front matter. Pair it with fetch-depth: 0 in the GitHub Actions checkout step, otherwise the shallow clone only sees the most recent commit:
1- uses: actions/checkout@v4
2 with:
3 submodules: true
4 fetch-depth: 0
noClasses = false is required by the terminal theme — without it, Hugo uses inline styles for syntax highlighting instead of CSS classes, overriding the theme’s syntax.css.
SEO#
H1 Heading#
The homepage content was using ## (H2) as the main heading. Changed to # (H1) — each page should have exactly one.
Page Title#
The <title> tag was just the site name. Expanded it to include role and keywords while staying within the 50–60 character sweet spot that tools like Yoast recommend:
1title = 'Igor Mihajlov | Go & TypeScript Backend Engineer'
Charset in HTTP Header#
UTF-8 was declared in the HTML <meta> tag but missing from the Content-Type HTTP response header. One line in the nginx server block:
1charset utf-8;
Custom 404 Page#
Hugo generates a 404.html from any layouts/404.html template. The terminal theme includes one, but nginx needs to be told to serve it on 404 errors:
1location / {
2 proxy_pass http://host.docker.internal:81;
3 ...
4 proxy_intercept_errors on;
5 error_page 404 /404.html;
6}
Security Headers#
The nginx Inheritance Gotcha#
Before adding headers, there’s an important nginx behaviour to understand: add_header directives in a location block replace the parent server block’s add_header directives — they do not merge. If you define headers at the server block level and then use add_header inside a location block, the server-level headers disappear for responses from that location.
The fix is to repeat all security headers in every location block that uses add_header.
The Headers#
1server_tokens off;
2
3add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
4add_header X-Content-Type-Options "nosniff" always;
5add_header X-Frame-Options "SAMEORIGIN" always;
6add_header Referrer-Policy "strict-origin-when-cross-origin" always;
7add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()" always;
server_tokens off— stops nginx from advertising its version number in error pages and response headersHSTS— tells browsers to always use HTTPS for this domain, even if the user typeshttp://nosniff— prevents browsers from MIME-sniffing responses away from the declared content typeSAMEORIGIN— blocks the page from being embedded in iframes on other domainsReferrer-Policy— limits referrer information sent when users navigate away from the sitePermissions-Policy— disables browser APIs the site doesn’t use (camera, microphone, geolocation, etc.)
Content Security Policy#
CSP is the most impactful security header and the most complex to get right. It whitelists every source of scripts, styles, fonts, and images — anything not on the list gets blocked.
For this site everything is self-hosted, so the policy can be strict:
1add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests;" always;
A few things worth noting:
- Cloudflare’s email obfuscation script is served from
/cdn-cgi/scripts/...— a path on your own domain — so'self'covers it - JSON-LD
<script type="application/ld+json">blocks are data, not executable JavaScript, so they’re not affected byscript-src frame-ancestors 'none'is the CSP equivalent ofX-Frame-Optionsand is preferred by modern browsersupgrade-insecure-requestsauto-upgrades any strayhttp://resource loads tohttps://
Before deploying a CSP, check the page source for inline scripts or styles — they require 'unsafe-inline', which significantly weakens the policy.
The Complete Nginx Config#
1server {
2 listen 80;
3 server_name mysite.com www.mysite.com;
4 return 301 https://mysite.com$request_uri;
5}
6
7server {
8 listen 443 ssl;
9 server_name www.mysite.com;
10
11 ssl_certificate /etc/nginx/ssl/mysite.com.pem;
12 ssl_certificate_key /etc/nginx/ssl/mysite.com.key;
13
14 return 301 https://mysite.com$request_uri;
15}
16
17server {
18 listen 443 ssl;
19 server_name mysite.com;
20
21 ssl_certificate /etc/nginx/ssl/mysite.com.pem;
22 ssl_certificate_key /etc/nginx/ssl/mysite.com.key;
23
24 server_tokens off;
25
26 gzip on;
27 gzip_types text/plain text/css application/javascript application/json image/svg+xml;
28 gzip_min_length 1024;
29 gzip_proxied any;
30 gzip_vary on;
31
32 charset utf-8;
33
34 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
35 add_header X-Content-Type-Options "nosniff" always;
36 add_header X-Frame-Options "SAMEORIGIN" always;
37 add_header Referrer-Policy "strict-origin-when-cross-origin" always;
38 add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests;" always;
39 add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()" always;
40
41 location ~* \.(css|js)$ {
42 proxy_pass http://host.docker.internal:81;
43 proxy_set_header Host $host;
44 proxy_set_header X-Real-IP $remote_addr;
45 proxy_redirect http:// https://;
46 expires 1y;
47 add_header Cache-Control "public, immutable";
48 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
49 add_header X-Content-Type-Options "nosniff" always;
50 add_header X-Frame-Options "SAMEORIGIN" always;
51 add_header Referrer-Policy "strict-origin-when-cross-origin" always;
52 add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests;" always;
53 add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()" always;
54 }
55
56 location / {
57 proxy_pass http://host.docker.internal:81;
58 proxy_set_header Host $host;
59 proxy_set_header X-Real-IP $remote_addr;
60 proxy_redirect http:// https://;
61 proxy_intercept_errors on;
62 error_page 404 /404.html;
63 }
64}
The Result#
After these changes the site scores A+ on securityheaders.com, passes Core Web Vitals, and has no redirect errors in Google Search Console. The remaining render-blocking resources are CSS — unavoidable without inlining critical styles, which isn’t worth the complexity for a terminal-themed site where essentially all CSS is critical.
The full git history for these changes is on GitHub.