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:

  1. Googlebot requests https://mysite.com/about
  2. The Hugo container’s nginx issues a 301 to http://mysite.com/about/ — absolute, HTTP, because the container only speaks HTTP internally
  3. The reverse proxy catches the HTTP request and issues another 301 to https://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 headers
  • HSTS — tells browsers to always use HTTPS for this domain, even if the user types http://
  • nosniff — prevents browsers from MIME-sniffing responses away from the declared content type
  • SAMEORIGIN — blocks the page from being embedded in iframes on other domains
  • Referrer-Policy — limits referrer information sent when users navigate away from the site
  • Permissions-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 by script-src
  • frame-ancestors 'none' is the CSP equivalent of X-Frame-Options and is preferred by modern browsers
  • upgrade-insecure-requests auto-upgrades any stray http:// resource loads to https://

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.