Though Google tries hard to stop my use of the Pixel XL, I continue to take high quality photos and video and they continue to hold up their end of the agreement to host them all as captured [… forever]. While Google Photos is great in that regard (a digital media dump), the tracking and stalking that goes along with sharing and uploading isn’t so nice.

Tired of mashing exiftool -all= * && mogrify -resize 33% * and scp ... after downloading archives from Google Photos, I decided to simplify the process and standup a simple image gallery service I can control and improve. See it in action: gallery.desmas.net

After a little time scouring docs and referencing many articles about caching and proxying in NGINX, the below config is what I settled on. I was surprised to find that all of this would work out of the box, thanks to the Image-Filter1 and core modules. The trick to caching it seems is to utilize a behind-the-scenes proxy server, which I wouldn’t have initially guessed.

The config here isn’t perfect: replacing images in a gallery won’t receive new thumbnails for two weeks (or until the cache is deleted), and galleries only update every half hour. As well, the gallery HTML isn’t perfect, though newer CSS technology like display: grid seem the ideal solution. And … videos are not scaled and downloaded in their entirity when viewing the gallery of thumbnails. 😅

gallery.example.com.conf

proxy_cache_path /home/gallery/cache levels=1:2 keys_zone=gallery_cache:10m max_size=10g inactive=48h use_temp_path=off;

server {
	server_name _;

	listen 4321;

	autoindex off;

	set $gallery_root /home/gallery/galleries;

	location / {
		root $gallery_root;

		# serve galleries
		location ~ ^/(.+)/$ {
		 	autoindex on;
			autoindex_format xml;

			xslt_string_param title $1;
			xslt_stylesheet /home/gallery/gallery.xslt;
		}

		# serve image thumbnails
		location ~ ^/(.+)!(thumb)$ {
			set $file /$1;

			if (-f $file) {
				break;
			}

			alias $gallery_root/$file;

			image_filter_jpeg_quality 90; # default 75
			image_filter_webp_quality 90; # default 80
			image_filter_interlace on; # default off
			image_filter_buffer 8M; # default 1M
			image_filter resize 640 480;
		}
	}
}

server {
	server_name gallery.example.com;
	access_log /home/gallery/gallery.log combined;

	listen 80;
	listen [::]:80;

	add_header X-Robots-Tag "none, noindex, nofollow" always;

	gzip on;
	gzip_comp_level 4;
	gzip_types text/css text/javascript text/plain text/xml application/javascript application/json application/x-javascript application/xml application/xml+rss;
	#gzip_proxied any;
	#gzip_vary on;

	autoindex off;

	set $gallery_root /home/gallery/galleries;

	location / {
		root $gallery_root;

		expires 2w;

		location = / {
		 	autoindex on;
		}

		# serve galleries
		location ~ ^/(?:.+)/$ {
			proxy_cache gallery_cache;
			proxy_cache_revalidate on;
			proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
			proxy_cache_background_update on;
			proxy_cache_lock on;
			proxy_set_header Host $host;
			proxy_buffering on;
			proxy_cache_valid 30m;
			proxy_cache_valid any 1m;
			proxy_pass http://127.0.0.1:4321;
		}

		# serve image thumbnails
		location ~ ^/(.+)!(thumb)$ {
			proxy_cache gallery_cache;
			proxy_cache_revalidate on;
			proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
			proxy_cache_background_update on;
			proxy_cache_lock on;
			proxy_set_header Host $host;
			proxy_buffering on;
			proxy_cache_valid 2w;
			proxy_cache_valid any 1m;
			proxy_pass http://127.0.0.1:4321;
		}
		
		# serve content
		location ~ ^/(?:.+)$ {
			try_files $uri $uri/ =404;
		}
	}
}

gallery.xslt

<?xml version="1.0" encoding="UTF-8"?>
<!-- https://stackoverflow.com/a/39575874 -->
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
	<xsl:output method="html" encoding="utf-8" indent="yes" />
	<xsl:template match="/">
	    <xsl:text disable-output-escaping='yes'>&lt;!doctype html&gt;</xsl:text>
<html lang="en-us">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<title>gallery.example.com - <xsl:value-of select="$title" /></title>
		<style>
html, body {
	background: #1f1f1f;
	display: block;
	width: 100%;
	height: 100%;
	margin: 0;
	text-align: center;
	font-family: sans-serif, monospace;
}
.gallery {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	justify-content: center;
	/*
	width: 100%;
	height: 100%;
	gap: 0.5em;
	margin: 0.5em 0 0 0.5em;
	*/
	margin: 0.5em;
}
.thumbnail {
	background: #3f3f3f;
	flex-grow: 1;
	max-width: 19.5%;
	margin: 0 0.5em 0.5em 0;
}
img, video {
	display: block;
	width: 100%;
	min-width: 20em;
	min-height: 20em;
	image-orientation: from-image;
}
		</style>
	</head>
	<body>
		<div class="gallery">
			<xsl:for-each select="list/file">
				<div class="thumbnail">
					<a href="{.}" target="_blank" style="display: block">
						<xsl:choose>
							<xsl:when test="contains(' mp4 webm mkv avi wmv flv ogv ', concat(' ', substring-after(., '.'), ' '))">
								<video controls="" src="{.}" alt="{.}" title="{.}" />
							</xsl:when>
							<xsl:otherwise>
								<img src="{.}!thumb" alt="{.}" title="{.}" />
							</xsl:otherwise>
						</xsl:choose>
					</a>
				</div>
			</xsl:for-each>
		</div>
	</body>
</html>
	</xsl:template>
</xsl:stylesheet>