diff --git a/README.md b/README.md
index 70c78c3..94f6df5 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,55 @@
-# website-but-better
+Marcus Allison — Portfolio (Static site)
+Quick deploy instructions for an nginx host (Debian/Ubuntu):
+
+1. Copy files to the web root on the server:
+
+ sudo mkdir -p /var/www/marcus
+ sudo cp -r "*" /var/www/marcus/ # run from the project folder or copy files individually
+ sudo chown -R www-data:www-data /var/www/marcus
+
+2. Place the nginx server block (nginx/marcus_allison.conf) into `/etc/nginx/sites-available/` and enable it:
+
+ sudo cp "nginx/marcus_allison.conf" /etc/nginx/sites-available/marcus_allison
+ sudo ln -s /etc/nginx/sites-available/marcus_allison /etc/nginx/sites-enabled/
+
+3. Test nginx config and reload:
+
+ sudo nginx -t
+ sudo systemctl reload nginx
+
+4. (Optional) Provision HTTPS with Certbot (Let's Encrypt):
+
+ sudo apt update && sudo apt install certbot python3-certbot-nginx
+ sudo certbot --nginx -d portfolio.example.com -d www.portfolio.example.com
+
+5. Docker alternative (serve static site with nginx container):
+
+ docker run --rm -p 80:80 -v "$(pwd):/usr/share/nginx/html:ro" nginx:stable
+
+Customize:
+- Replace `portfolio.example.com` with your real domain.
+- Update contact email in `index.html` (now set to marcus.sailer@organic-server.org).
+
+Photography gallery:
+- To regenerate `images/manifest.json` after adding/removing photos, run:
+
+```bash
+python3 scripts/generate_manifest.py
+```
+
+- Alternatively, enable Nginx directory listing for `/images/` so the site can probe the directory. Example server block fragment for `/images/` only:
+
+```
+location /images/ {
+ alias /var/www/marcus/images/;
+ autoindex on;
+}
+```
+
+Editing the Git link:
+- Update the header Git link in `index.html` at the element with id `git-link` to your repository URL.
+
+If you want, I can also:
+- Add a Docker Compose service for a static site + automatic certs.
+- Produce a small resume/JSON-LD metadata file for SEO.
diff --git a/images/DSC_0083.jpg b/images/DSC_0083.jpg
new file mode 100644
index 0000000..ff95aad
Binary files /dev/null and b/images/DSC_0083.jpg differ
diff --git a/images/DSC_0084.jpg b/images/DSC_0084.jpg
new file mode 100644
index 0000000..73b1d33
Binary files /dev/null and b/images/DSC_0084.jpg differ
diff --git a/images/DSC_0114.jpg b/images/DSC_0114.jpg
new file mode 100644
index 0000000..8068dff
Binary files /dev/null and b/images/DSC_0114.jpg differ
diff --git a/images/DSC_0122.jpg b/images/DSC_0122.jpg
new file mode 100644
index 0000000..5a542c1
Binary files /dev/null and b/images/DSC_0122.jpg differ
diff --git a/images/DSC_0207.jpg b/images/DSC_0207.jpg
new file mode 100644
index 0000000..e721875
Binary files /dev/null and b/images/DSC_0207.jpg differ
diff --git a/images/DSC_0214.jpg b/images/DSC_0214.jpg
new file mode 100644
index 0000000..19873b5
Binary files /dev/null and b/images/DSC_0214.jpg differ
diff --git a/images/DSC_0224.jpg b/images/DSC_0224.jpg
new file mode 100644
index 0000000..8c1a2f3
Binary files /dev/null and b/images/DSC_0224.jpg differ
diff --git a/images/DSC_0234.jpg b/images/DSC_0234.jpg
new file mode 100644
index 0000000..9f25859
Binary files /dev/null and b/images/DSC_0234.jpg differ
diff --git a/images/DSC_0319.jpg b/images/DSC_0319.jpg
new file mode 100644
index 0000000..3a4b6f2
Binary files /dev/null and b/images/DSC_0319.jpg differ
diff --git a/images/DSC_0337.jpg b/images/DSC_0337.jpg
new file mode 100644
index 0000000..8cf4258
Binary files /dev/null and b/images/DSC_0337.jpg differ
diff --git a/images/DSC_0455.jpg b/images/DSC_0455.jpg
new file mode 100644
index 0000000..f2e98fa
Binary files /dev/null and b/images/DSC_0455.jpg differ
diff --git a/images/DSC_0470.jpg b/images/DSC_0470.jpg
new file mode 100644
index 0000000..a080098
Binary files /dev/null and b/images/DSC_0470.jpg differ
diff --git a/images/_DSC0582.jpg b/images/_DSC0582.jpg
new file mode 100644
index 0000000..03ba86c
Binary files /dev/null and b/images/_DSC0582.jpg differ
diff --git a/images/_DSC0621.jpg b/images/_DSC0621.jpg
new file mode 100644
index 0000000..e822faf
Binary files /dev/null and b/images/_DSC0621.jpg differ
diff --git a/images/_DSC0646.jpg b/images/_DSC0646.jpg
new file mode 100644
index 0000000..16bed59
Binary files /dev/null and b/images/_DSC0646.jpg differ
diff --git a/images/_DSC0649.jpg b/images/_DSC0649.jpg
new file mode 100644
index 0000000..bf79e5a
Binary files /dev/null and b/images/_DSC0649.jpg differ
diff --git a/images/_DSC0699.jpg b/images/_DSC0699.jpg
new file mode 100644
index 0000000..5f69316
Binary files /dev/null and b/images/_DSC0699.jpg differ
diff --git a/images/_DSC0707.jpg b/images/_DSC0707.jpg
new file mode 100644
index 0000000..7feed02
Binary files /dev/null and b/images/_DSC0707.jpg differ
diff --git a/images/_DSC0833.jpg b/images/_DSC0833.jpg
new file mode 100644
index 0000000..fa0a3c0
Binary files /dev/null and b/images/_DSC0833.jpg differ
diff --git a/images/_DSC0858.jpg b/images/_DSC0858.jpg
new file mode 100644
index 0000000..056b8c2
Binary files /dev/null and b/images/_DSC0858.jpg differ
diff --git a/images/_DSC1015.jpg b/images/_DSC1015.jpg
new file mode 100644
index 0000000..906aca7
Binary files /dev/null and b/images/_DSC1015.jpg differ
diff --git a/images/_DSC1028.jpg b/images/_DSC1028.jpg
new file mode 100644
index 0000000..d058bf6
Binary files /dev/null and b/images/_DSC1028.jpg differ
diff --git a/images/_DSC1152.jpg b/images/_DSC1152.jpg
new file mode 100644
index 0000000..2a648e8
Binary files /dev/null and b/images/_DSC1152.jpg differ
diff --git a/images/_DSC1174.jpg b/images/_DSC1174.jpg
new file mode 100644
index 0000000..75f7acc
Binary files /dev/null and b/images/_DSC1174.jpg differ
diff --git a/images/_DSC1330.jpg b/images/_DSC1330.jpg
new file mode 100644
index 0000000..dc09a8f
Binary files /dev/null and b/images/_DSC1330.jpg differ
diff --git a/images/manifest.json b/images/manifest.json
new file mode 100644
index 0000000..8ad657e
--- /dev/null
+++ b/images/manifest.json
@@ -0,0 +1,27 @@
+[
+ "DSC_0083.jpg",
+ "DSC_0084.jpg",
+ "DSC_0114.jpg",
+ "DSC_0122.jpg",
+ "DSC_0207.jpg",
+ "DSC_0214.jpg",
+ "DSC_0224.jpg",
+ "DSC_0234.jpg",
+ "DSC_0319.jpg",
+ "DSC_0337.jpg",
+ "DSC_0455.jpg",
+ "DSC_0470.jpg",
+ "_DSC0582.jpg",
+ "_DSC0621.jpg",
+ "_DSC0646.jpg",
+ "_DSC0649.jpg",
+ "_DSC0699.jpg",
+ "_DSC0707.jpg",
+ "_DSC0833.jpg",
+ "_DSC0858.jpg",
+ "_DSC1015.jpg",
+ "_DSC1028.jpg",
+ "_DSC1152.jpg",
+ "_DSC1174.jpg",
+ "_DSC1330.jpg"
+]
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f845b86
--- /dev/null
+++ b/index.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ Marcus Allison - Systems Administrator & Self-Hosting Portfolio
+
+
+
+
+
+
+
+ Professional Summary
+ I am a passionate and dedicated systems administrator and self-hosting enthusiast with a comprehensive, production-grade home lab. My infrastructure demonstrates a deep understanding of containerization, reverse proxy configuration, network security, and the deployment of a wide array of open-source services. I thrive on creating robust, private, and self-sufficient digital ecosystems.
+
+
+
+ Technical Skills Showcase
+ My portfolio is a living environment where I actively use and manage the technologies below.
+
+
+
Infrastructure & Orchestration
+
Docker, LXC, Nginx Proxy Manager, DDNS
+
+
+
Network & Security
+
WireGuard, Pi-hole, OpenWrt, Vaultwarden
+
+
+
Cloud & Storage
+
Immich, Synology DSM, Zipline
+
+
+
Communication & Collaboration
+
Matrix Synapse, Gitea
+
+
+
AI & Automation
+
Ollama, Open WebUI, Home Assistant (HAOS)
+
+
+
Web & Data
+
SearXNG, Kiwix, Trilium, Homarr
+
+
+
Monitoring & Time
+
NTP server, UISP (UniFi OS Server)
+
+
+
+
+
+ Key Infrastructure Projects
+
+ Secure and Private Self-Hosted Ecosystem: Designed and deployed a comprehensive suite of 20+ services on a single server, replacing numerous third-party cloud services.
+ Centralized Access and Security: Implemented Nginx Proxy Manager for SSL termination and reverse proxying, with WireGuard for secure admin access.
+ Local AI Integration: Deployed Ollama and Open WebUI to create a private, offline AI playground.
+ Resilient and Automated Network: Combined Pi-hole, OpenWrt, and DDNS to provide a stable, secure network on a dynamic IP.
+
+ Data Ownership and Management: Immich for photos, Vaultwarden for passwords, Gitea for code, and Synology DSM for centralized storage.
+
+
+
+
+ Photography
+ A selection from my photography. Click a thumbnail for a fullscreen preview. The gallery updates when images are added or a manifest is regenerated.
+ Loading images…
+
+
+
×
+
‹
+
+
›
+
+
+
+
+ Public Service Links
+ May require authentication:
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/gallery.js b/js/gallery.js
new file mode 100644
index 0000000..2928f12
--- /dev/null
+++ b/js/gallery.js
@@ -0,0 +1,103 @@
+const IMAGE_DIR = 'images/';
+const MANIFEST = IMAGE_DIR + 'manifest.json';
+
+function parseIndexHtmlList(text) {
+ // crude parser to extract hrefs from an autoindex HTML listing
+ const hrefs = [];
+ const re = /href="([^"]+)"/g;
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ const name = m[1];
+ if (name.match(/\.(jpe?g|png|webp|gif)$/i)) hrefs.push(name);
+ }
+ return hrefs;
+}
+
+async function loadImageList() {
+ // Try manifest first
+ try {
+ const r = await fetch(MANIFEST, {cache: 'no-cache'});
+ if (r.ok) return await r.json();
+ } catch (e) {
+ // continue to directory probe
+ }
+
+ try {
+ const r = await fetch(IMAGE_DIR, {cache: 'no-cache'});
+ if (r.ok) {
+ const text = await r.text();
+ return parseIndexHtmlList(text);
+ }
+ } catch (e) {
+ console.warn('Could not read image directory listing', e);
+ }
+ return [];
+}
+
+function createThumb(src, idx) {
+ const img = document.createElement('img');
+ img.src = src;
+ img.loading = 'lazy';
+ img.dataset.index = idx;
+ img.alt = `Photo ${idx+1}`;
+ img.addEventListener('click', () => openModal(idx));
+ return img;
+}
+
+let images = [];
+let currentIndex = 0;
+
+function openModal(idx) {
+ currentIndex = idx;
+ const modal = document.getElementById('gallery-modal');
+ const mimg = document.getElementById('modal-image');
+ mimg.src = IMAGE_DIR + images[idx];
+ modal.classList.remove('hidden');
+ modal.setAttribute('aria-hidden', 'false');
+}
+
+function closeModal() {
+ const modal = document.getElementById('gallery-modal');
+ modal.classList.add('hidden');
+ modal.setAttribute('aria-hidden', 'true');
+ document.getElementById('modal-image').src = '';
+}
+
+function showNext(delta=1) {
+ if (!images.length) return;
+ currentIndex = (currentIndex + delta + images.length) % images.length;
+ document.getElementById('modal-image').src = IMAGE_DIR + images[currentIndex];
+}
+
+function setupModalControls() {
+ document.getElementById('modal-close').addEventListener('click', closeModal);
+ document.getElementById('modal-prev').addEventListener('click', () => showNext(-1));
+ document.getElementById('modal-next').addEventListener('click', () => showNext(1));
+ document.addEventListener('keydown', (e) => {
+ if (document.getElementById('gallery-modal').classList.contains('hidden')) return;
+ if (e.key === 'Escape') closeModal();
+ if (e.key === 'ArrowRight') showNext(1);
+ if (e.key === 'ArrowLeft') showNext(-1);
+ });
+ document.getElementById('gallery-modal').addEventListener('click', (e)=>{
+ if (e.target.id === 'gallery-modal') closeModal();
+ });
+}
+
+async function initGallery() {
+ const list = await loadImageList();
+ images = list;
+ const container = document.getElementById('gallery-thumbs');
+ container.innerHTML = '';
+ if (!images.length) {
+ container.textContent = 'No images found. Create images/manifest.json or enable directory listing.';
+ return;
+ }
+ images.forEach((name, idx) => {
+ const thumb = createThumb(IMAGE_DIR + name, idx);
+ container.appendChild(thumb);
+ });
+ setupModalControls();
+}
+
+document.addEventListener('DOMContentLoaded', initGallery);
diff --git a/nginx/marcus_allison.conf b/nginx/marcus_allison.conf
new file mode 100644
index 0000000..1c74e7f
--- /dev/null
+++ b/nginx/marcus_allison.conf
@@ -0,0 +1,27 @@
+server {
+ listen 80;
+ server_name portfolio.example.com www.portfolio.example.com;
+
+ root /var/www/marcus;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ # Uncomment to redirect all HTTP to HTTPS if you provision certs separately:
+ # return 301 https://$host$request_uri;
+}
+
+# Example HTTPS block (use certbot or your CA to populate cert paths):
+# server {
+# listen 443 ssl http2;
+# server_name portfolio.example.com;
+# root /var/www/marcus;
+# index index.html;
+# ssl_certificate /etc/letsencrypt/live/portfolio.example.com/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/portfolio.example.com/privkey.pem;
+# include /etc/letsencrypt/options-ssl-nginx.conf;
+# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+# location / { try_files $uri $uri/ =404; }
+# }
diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py
new file mode 100644
index 0000000..8c5c0d8
--- /dev/null
+++ b/scripts/generate_manifest.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""
+Generate `images/manifest.json` from the files present in the images/ folder.
+Run this in the project root to refresh the manifest after adding/removing photos.
+"""
+import os
+import json
+
+IMAGEDIR = os.path.join(os.path.dirname(__file__), '..', 'images')
+IMAGEDIR = os.path.abspath(IMAGEDIR)
+
+def main():
+ exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif')
+ files = [f for f in sorted(os.listdir(IMAGEDIR)) if f.lower().endswith(exts)]
+ out = os.path.join(IMAGEDIR, 'manifest.json')
+ with open(out, 'w', encoding='utf-8') as fh:
+ json.dump(files, fh, indent=2)
+ print(f'Wrote {len(files)} entries to {out}')
+
+if __name__ == '__main__':
+ main()
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..f474e87
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,45 @@
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#16202A; background:#f7fafc; line-height:1.6; }
+.container { max-width:980px; margin:0 auto; padding:24px; }
+.site-header { background:linear-gradient(90deg,#0f172a,#0b1320); color:#fff; padding:20px 0; border-bottom:3px solid #0f62fe; }
+.site-header .container { padding:20px 24px; }
+.site-header h1 { margin:0; font-size:2.2rem; font-weight:700; letter-spacing:-0.5px; }
+.tag { margin-top:6px; opacity:0.9; font-size:0.95rem; }
+.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:16px; margin-top:16px; }
+.card { background:#fff; padding:20px; border-radius:8px; box-shadow:0 1px 3px rgba(2,6,23,0.06); transition:all 0.3s ease; border:1px solid #e5e7eb; }
+.card:hover { transform:translateY(-2px); box-shadow:0 8px 16px rgba(2,6,23,0.1); }
+.site-footer { text-align:center; padding:24px; margin-top:40px; font-size:0.9rem; color:#6b7280; border-top:1px solid #e5e7eb; background:#fafafa; }
+h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#0f172a; padding-bottom:12px; border-bottom:2px solid #f0f0f0; }
+h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#0f172a; }
+section { margin-bottom:16px; }
+p { margin:0 0 12px 0; }
+ol, ul { margin:12px 0; padding-left:24px; }
+li { margin-bottom:8px; }
+a { color:#0f62fe; text-decoration:none; transition:color 0.2s ease; }
+a:hover { color:#0b47b8; text-decoration:underline; }
+
+#links ul { list-style:none; padding:0; display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0; }
+#links li { margin:0; }
+#links a { display:block; padding:12px 16px; background:#fff; border:1px solid #e5e7eb; border-radius:6px; transition:all 0.3s ease; }
+#links a:hover { border-color:#0f62fe; box-shadow:0 4px 12px rgba(15,98,254,0.15); }
+@media (max-width:520px){ h2 { font-size:1.4rem; } .site-header h1 { font-size:1.6rem; } .container { padding:16px; } }
+
+.header-links { margin-top:12px; }
+.header-links a { color:#fff; background:rgba(255,255,255,0.12); padding:8px 14px; border-radius:6px; text-decoration:none; transition:all 0.2s ease; display:inline-block; font-size:0.95rem; font-weight:500; }
+.header-links a:hover { background:rgba(255,255,255,0.25); }
+
+.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-top:16px; }
+.gallery img { width:100%; height:150px; object-fit:cover; border-radius:8px; cursor:pointer; display:block; transition:all 0.3s ease; border:2px solid transparent; }
+.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(2,6,23,0.15); border-color:#0f62fe; }
+
+.modal { position:fixed; inset:0; background:rgba(2,6,23,0.95); display:flex; align-items:center; justify-content:center; z-index:60; backdrop-filter:blur(2px); }
+.modal.hidden { display:none; }
+.modal img { max-width:95%; max-height:90%; border-radius:8px; box-shadow:0 16px 48px rgba(2,6,23,0.4); animation:fadeInScale 0.3s ease; }
+@keyframes fadeInScale { from { opacity:0; transform:scale(0.95); } to { opacity:1; transform:scale(1); } }
+.modal-close, .modal-prev, .modal-next { position:fixed; background:rgba(255,255,255,0.1); color:#fff; border:0; font-size:36px; cursor:pointer; transition:all 0.2s ease; width:48px; height:48px; display:flex; align-items:center; justify-content:center; border-radius:6px; }
+.modal-close:hover, .modal-prev:hover, .modal-next:hover { background:rgba(255,255,255,0.2); }
+.modal-close { right:20px; top:20px; }
+.modal-prev { left:20px; top:50%; transform:translateY(-50%); }
+.modal-next { right:20px; top:50%; transform:translateY(-50%); }
+