[ jd303 ]

Greetings, fellow baud bandits! Running a home lab is something every 31337 h@ck3r needs to do. Today we'll be learning how to run a local web server on a Raspberry Pi 5 using nginx and Docker Compose. In future installments of this series, we'll install Pi-hole for local DNS (and ad-blocking) so everything gets a real hostname on your network, create a local Certificate Authority for secure connections, set up Home Assistant, and lots of other interesting things!

These are detailed instructions for the larval stage on how to do this. If you want to be a lamer you can also just skip to the warez section and use the bootstrap to get this all running immediately.

[ jd303 ]

Hardware

  • Raspberry Pi 5 8GB ($174.99, Microcenter)
  • CanaKit Black Case for Pi 5 (with built-in fan)
  • Raspberry Pi Active Cooler (official)
  • 128GB SanDisk microSD
  • Raspberry Pi 45W USB-C Power Supply ($14.99, Microcenter)

Raspberry Pi 5 8GB, CanaKit case, active cooler, SD card, and 45W power supply unboxed

Raspberry Pi 5 assembled in CanaKit case with active cooler installed

[ claude ]

What We're Building

The foundation of the stack: nginx running in Docker Compose on Raspberry Pi OS, serving a static site on your local network.

  • nginx, web server and reverse proxy, routes by hostname, handles 80/443

Why Docker Compose: keeps services isolated, easy to update, easy to back up. No fighting with system packages.

Why nginx over a GUI proxy manager (like Nginx Proxy Manager): config files are plain text. That means everything lives in a git repo. Restore to a new Pi with git clone and one command. A GUI stores config in a database, you restore from a backup, not a repo.

All config lives in ct-homelab on GitHub.

In this post:

  • Flash Raspberry Pi OS to SD card
  • First boot and SSH setup
  • Install zsh + oh-my-zsh + .zshrc
  • Install Docker + Docker Compose
  • Configure nginx
  • Deploy the site
  • Test from other devices on network
[ jd303 + claude ]

Flashing the SD Card

Use Raspberry Pi OS Lite (64-bit), no desktop, smaller footprint, right tool for a headless server.

Generate an SSH Key

An SSH key is two files that work as a pair: a private key (stays on your machine, never shared) and a public key (goes on the server). When you connect, your machine proves it holds the private key without transmitting it. No password, nothing to brute-force remotely.

Generate a dedicated key for your homelab:

ssh-keygen -t ed25519 -C "homelab" -f ~/.ssh/homelab

This creates ~/.ssh/homelab (private) and ~/.ssh/homelab.pub (public). Lock down the private key:

chmod 600 ~/.ssh/homelab

SSH will refuse to use a key that other users can read, 600 means only your account can touch it.

Print the public key, you'll paste this into the Imager in Step 4 below:

cat ~/.ssh/homelab.pub

Download from raspberrypi.com/software. During install, Windows will prompt you to install the Raspberry Pi USB driver, click Install, it's legit.

Windows Security prompt to install Raspberry Pi USB driver

Raspberry Pi Imager setup wizard complete screen

Walk through the steps:

Step 1, Device: Select Raspberry Pi 5.

Raspberry Pi Imager device selection screen with Raspberry Pi 5 highlighted

Step 2, OS: The Imager defaults to the full desktop version, don't use that for a server. Scroll past the desktop options and click Raspberry Pi OS (other), then select Raspberry Pi OS Lite (64-bit). No desktop environment, 550MB download vs 1.3GB+, less RAM overhead. There's no point running a GUI on a machine you'll never plug a monitor into.

Raspberry Pi Imager OS selection showing Raspberry Pi OS (other) option

Raspberry Pi OS Lite (64-bit) selected in Raspberry Pi Imager

Step 3, Storage: Select your SD card. Double-check the size, don't accidentally pick your main drive.

Raspberry Pi Imager storage selection showing SDXC card

Step 4, Customisation: This is the important part. The Imager lets you pre-configure everything before flashing so you never need a keyboard or monitor on the Pi.

  • Hostname: set it here (e.g. raspberrypi)

Raspberry Pi Imager customisation screen showing hostname set to raspberrypi

  • Localisation: set your timezone and keyboard layout

Raspberry Pi Imager localisation screen showing America/Denver timezone

  • User: set your username and password (you'll need this even if using SSH keys)

Raspberry Pi Imager user account setup screen

  • Wi-Fi: skip if using ethernet, leave it blank

Raspberry Pi Imager Wi-Fi configuration screen left blank

  • SSH: enable it. Two options:
    • Public key (recommended): passwordless, more secure. Generate a key first if you haven't, see Generate an SSH Key above. Paste the contents of ~/.ssh/homelab.pub.
    • Password auth: simpler, fine for a home network.
  • Raspberry Pi Connect: skip it. Cloud remote access that routes traffic through Pi's servers, requires an account. You have SSH, you don't need it.

Raspberry Pi Imager SSH authentication screen with public key authentication selected

Step 5, Write: Review the summary, click Write, confirm the erase warning. Do not disconnect the SD card while writing.

Raspberry Pi Imager write summary showing Raspberry Pi OS Lite (64-bit) and all customisations

Raspberry Pi Imager erase confirmation dialog

Done. SD card is ejected automatically when writing completes, you can pull it out and put it in the Pi.

Raspberry Pi Imager write complete screen confirming Raspberry Pi OS Lite (64-bit) with all customisations applied

Option 2: Native Linux (dd)

# Find your SD card, look for your card's size
lsblk

# Flash it, DOUBLE CHECK the device, dd will nuke whatever you point it at
sudo dd if=raspios-bookworm-arm64-lite.img of=/dev/sdX bs=4M status=progress conv=fsync

Option 3: WSL2

WSL2 doesn't see USB block devices by default. You need usbipd-win to bridge them.

In PowerShell (Admin):

winget install usbipd

# Find your SD card reader
usbipd list

# Bind it (one-time, requires admin)
usbipd bind --busid X-X

# Attach to WSL
usbipd attach --wsl --busid X-X

Then in WSL:

lsblk  # SD card should now appear as /dev/sdX
sudo dd if=raspios-bookworm-arm64-lite.img of=/dev/sdX bs=4M status=progress conv=fsync

Detach when done (PowerShell):

usbipd detach --busid X-X

Honest take: if you're on WSL2, just use the Imager on Windows. The usbipd route works but it's extra steps for no real benefit unless you live in the terminal.

[ jd303 + claude ]

First Boot and SSH

Pop the SD card in the Pi and power it on. Green LED blinking = it's booting and reading the card. Give it 30-60 seconds.

Connecting

If you haven't generated a key yet, go back and do that first, you'll need it here.

If you used a custom key name (e.g. homelab), SSH won't pick it up automatically. Specify it explicitly on first connect:

ssh -i ~/.ssh/homelab jd@raspberrypi.local

The .local mDNS hostname works out of the box on most networks, no need to look up the IP.

SSH Config

Do this on your local machine (WSL, Linux, or macOS), not on the Pi.

Add an entry to ~/.ssh/config so you never have to specify the key or hostname again:

Host raspberrypi
    HostName raspberrypi.local
    User jd
    IdentityFile ~/.ssh/homelab

After that, connecting is just:

ssh raspberrypi

If you're using the official Pi 5 active cooler, the fan is temperature-controlled and won't spin at idle, that's normal.1

[ jd303 + claude ]

Install zsh + oh-my-zsh

Raspberry Pi OS Lite ships with bash. Zsh is a better interactive shell: smarter tab completion, better history, cleaner config. Oh My Zsh is a framework that sits on top of it, adding themes, plugins, and git-aware prompts. Install both.

Every shell has an "rc" file that loads when you open a terminal. It's where you configure your environment: aliases (shortcuts for commands), environment variables (persistent settings the shell and other programs read), and anything else you want set up automatically every session. Bash uses .bashrc, zsh uses .zshrc. When we install Docker shortly, the aliases in here will save you a lot of typing.

First, install zsh and oh-my-zsh:

sudo apt update && sudo apt upgrade -y
sudo apt install zsh git -y
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

The oh-my-zsh installer will ask if you want to set zsh as your default shell, say yes. It creates a starter .zshrc automatically.

Now replace that starter config with our stripped-down version. Clear the file, then open it with nano:2

truncate -s 0 ~/.zshrc
nano ~/.zshrc

Paste the following, then press Ctrl+O to save, Enter to confirm, and Ctrl+X to exit:

Stop here before you paste. Two variables in this config control where everything lives:

  • HOMELAB_DIR, where your Docker stack lives (default: ~/ct-homelab)
  • MAIN_SITE_DIR, where your site files are served from (default: ~/sites/ct-site)

Every command in this guide uses these variables. If you want different paths, change them here, this is the only place you'll need to.

export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME=""
plugins=(git docker)
source $ZSH/oh-my-zsh.sh

# history
unsetopt share_history

# colors
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias ll='ls -alF'
alias l='ls -CF'

# docker
alias dc='docker compose'
alias dps='docker ps'
alias dimg='docker images'

# homelab
export HOMELAB_DIR=~/ct-homelab
export MAIN_SITE_DIR=~/sites/ct-site

# prompt: [user@host][time][~/path](git)->
PROMPT='$FG[015][$FG[010]%n@%m$FG[015]][$FG[244]%t$FG[015]][$FG[087]%~$FG[015]]$FG[010]$(git_prompt_info)$FG[015]-> '

Load it without logging out:

source ~/.zshrc

Your prompt should now show [user@host][time][~/path]-> in color, with the current git branch appended when you're inside a repo. The docker aliases will be useful once we get to the next step.

[ jd303 + claude ]

Install Docker

Don't use the apt version of Docker. It lags significantly behind upstream and will cause you headaches when compose features don't match the docs. Use the official install script instead:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker

usermod adds your user to the docker group so you don't have to sudo every Docker command. newgrp activates that group membership in your current session without requiring a full logout.

Verify both installed correctly:

docker --version
docker compose version

Both should return version strings. If docker compose version fails, the Compose plugin isn't installed, check the Docker install docs for Debian.

[ jd303 + claude ]

nginx Config

$HOMELAB_DIR is set in your .zshrc, ~/ct-homelab by default. Change it there if you want the stack somewhere else. Everything below uses it so the commands are copy-pasteable either way.

Create the directory structure:

mkdir -p $HOMELAB_DIR/nginx/templates
cd $HOMELAB_DIR

Read before you type. The commands below use a heredoc, a shell trick for writing multi-line files in one paste. Copy and paste each block as a single unit, including the final EOF line. If you're using nano instead: open the file, type only the contents between the EOF markers, then save and exit. Do not type EOF itself.

The stack uses a custom nginx Docker image, right now it's just FROM nginx:alpine, but having a Dockerfile means you can extend it later without touching docker-compose.yml. Create it, or if you prefer, nano nginx/Dockerfile, type FROM nginx:alpine, then Ctrl+OEnterCtrl+X to save:

cat > nginx/Dockerfile << 'EOF'
FROM nginx:alpine
EOF

Site configs live in nginx/templates/ as .conf.template files. The nginx container processes them with envsubst at startup, substituting environment variables automatically. Create the template for this site, or nano nginx/templates/main-site.conf.template and type the contents yourself, then Ctrl+OEnterCtrl+X to save:

cat > nginx/templates/main-site.conf.template << 'EOF'
server {
    listen 80;
    server_name ${MAIN_SITE_HOST};
    root /var/www/main;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

Create docker-compose.yml:

cat > docker-compose.yml << 'EOF'
services:
  nginx:
    build: ./nginx
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/templates:/etc/nginx/templates:ro
      - ${MAIN_SITE_DIR}:/var/www/main:ro
    environment:
      - MAIN_SITE_HOST=${MAIN_SITE_HOST}
EOF

The templates directory mounts read-only into the container. Site content mounts from ~/sites/ct-site on the Pi, that's where you deploy the built files.

Create .env with your configuration:

cat > .env << 'EOF'
TZ=America/Denver
HOSTNAME=raspberrypi
DOMAIN=ct.home
MAIN_SITE_HOST=conspicuoustechnologist.ct.home
EOF

Set TZ to your timezone, HOSTNAME to your Pi's hostname, and DOMAIN to your local domain. MAIN_SITE_HOST is the hostname Pi-hole will resolve to your Pi's IP in the next section, the .home TLD is conventional for local networks.

Create the directory nginx will serve files from:

mkdir -p $MAIN_SITE_DIR

Bring it up:

docker compose up -d
docker ps
docker logs nginx
[ jd303 + claude ]

Deploy the Site

Site content lives outside the stack, nginx mounts it from $MAIN_SITE_DIR on the Pi (~/sites/ct-site by default, set in .zshrc).

From your dev machine (WSL, Linux, or macOS, wherever you've been SSHing from), build your site and rsync the output over. The build command and output directory depend on your static site generator, Hugo uses hugo build and outputs to public/, Astro uses astro build and outputs to dist/, others vary. The rsync command is the same either way:

cd /path/to/your/site
# hugo build        # Hugo
# astro build       # Astro
rsync -avz --delete your-output-dir/ raspberrypi:$MAIN_SITE_DIR/

The --delete flag removes files on the Pi that no longer exist in the build. After the first deploy, subsequent deploys are the same commands.

If you want a one-command deploy, add a function to your dev machine's .zshrc, adjust the site path, build command, and output directory for your setup:

ctpi() {
  ( cd /path/to/your/site && \
    your-build-command && \
    rsync -avz --delete your-output-dir/ raspberrypi:$MAIN_SITE_DIR/ && \
    ssh raspberrypi 'docker restart nginx' )
}

Then source ~/.zshrc and ctpi deploys in one shot.

Hugo users: if you want to preview draft posts or future-dated posts on the Pi before publishing, add -D --buildFuture to your build command. -D includes content marked draft: true; --buildFuture includes posts with a publish date that hasn't arrived yet. Hugo excludes both by default, so without these flags you won't see unpublished content on the Pi.

hugo build -D --buildFuture

At this point nginx is serving the site on port 80, but local DNS isn't set up yet, that's Pi-hole in the next section. For now you can test directly by IP. Run this on the Pi to get its address:

hostname -I | awk '{print $1}'

Then hit that IP in curl or paste it into a browser, including Windows if you're on WSL:

curl http://<pi-ip>
[ out of band ]

1. Fan not spinning. The Pi 5 active cooler fan is temperature-controlled, it won't spin at idle and that's expected. If it never spins under load, the JST connector likely isn't fully seated. Power down, press it in until it clicks, reboot. Verify with:

ls /sys/class/thermal/                                           # should show cooling_device0 + thermal_zone0
cat /sys/class/thermal/cooling_device0/type                     # should say pwm-fan
cat /sys/class/thermal/cooling_device0/cur_state                # current speed (0=off)
echo 3 | sudo tee /sys/class/thermal/cooling_device0/cur_state  # force max to test

↩︎

2. I get it, you power users can use vi/vim. You lunatics can use Emacs.

↩︎