Knowledge / Laboratory / LAB-004
The Docker files behind this site: build, publish, auto-update, tunnel
The Dockerfile, Compose file and CI workflow that run this site on a home server, plus the Wazuh lab VM, sanitized so you can copy them.
This site runs in a container on my own server. Pushing to main is the whole deploy: GitHub builds the image, the server notices it and restarts the site, and a tunnel exposes it with no open router ports.
Below are the real files, with only my names swapped for placeholders. Replace <your-user> and <your-site> and you have the same setup.
The flow
git pushtomain.- GitHub Actions builds the image and publishes it to GitHub Container Registry (GHCR).
- Watchtower on the server polls GHCR every 5 minutes and restarts the site with the new image.
- cloudflared keeps an outbound Cloudflare Tunnel open, so the site is public without forwarding any port.
Dockerfile
Three stages: install dependencies, build, and a small runtime image that runs as a non-root user. It relies on Next.js output: "standalone".
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:24-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Posts are read from disk at runtime, so the MDX files ship with the image.
COPY --from=builder --chown=nextjs:nodejs /app/content ./content
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["node", "server.js"]
.dockerignore
Keeps secrets and build noise out of the image.
node_modules
.next
.git
.gitignore
Dockerfile
.dockerignore
compose.yaml
README.md
npm-debug.log*
.env*
compose.yaml
This is the only file the server needs. The tunnel token lives in a .env file next to it and never in the repository.
services:
web:
image: ghcr.io/<your-user>/<your-site>:latest
container_name: web
restart: unless-stopped
# Reachable only from this machine; the public path is the tunnel below.
ports:
- "127.0.0.1:3000:3000"
labels:
- "com.centurylinklabs.watchtower.enable=true"
watchtower:
image: nickfedor/watchtower:1.23.2
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Reuses the `docker login ghcr.io` credentials so Watchtower can pull
# the private image.
- ${HOME}/.docker/config.json:/config.json:ro
environment:
WATCHTOWER_LABEL_ENABLE: "true" # only touch containers with the label above
WATCHTOWER_POLL_INTERVAL: "300" # check for a new image every 5 minutes
WATCHTOWER_CLEANUP: "true" # delete old images after updating
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN:?Set TUNNEL_TOKEN in the .env file}
depends_on:
- web
labels:
- "com.centurylinklabs.watchtower.enable=true"
The .env file:
TUNNEL_TOKEN=<token from the Cloudflare Zero Trust dashboard>
GitHub Actions workflow
Save it as .github/workflows/docker.yml. Every push to main builds and publishes the image, tagged latest and with the commit SHA (handy for rollbacks). Actions are pinned by commit SHA.
name: Publish Docker image
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest
type=sha
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Setting it up
- Create a Cloudflare Tunnel, point its public hostname at
http://web:3000, and copy the token. - On the server, run
docker login ghcr.iowith a token that can read packages. - Copy
compose.yamland the.envfile to the server and rundocker compose up -d. - Push to
mainand watch the site update by itself.
Things to know
- Watchtower mounts the Docker socket, which is effectively root on the host. It is a deliberate trade-off for a home lab; pin the image version (as above) and do not expose it.
- The site port is bound to
127.0.0.1, so nothing is reachable except through the tunnel. - Nothing secret is in the image or the repository: the token comes from
.env, and.dockerignoreexcludes.env*.
The Wazuh lab: a Windows VM in Docker
The Wazuh SIEM itself is the official single-node stack (4.14.6) from wazuh/wazuh-docker, the single-node folder, unchanged. What I added is the first monitored endpoint: a Windows 10 LTSC VM that runs inside Docker (QEMU/KVM) with dockur/windows and installs the Wazuh agent by itself.
Layout:
windows-ws/
├── compose.yaml
├── .env.example
├── oem/
│ └── install.bat
└── storage/ # VM disk (do not commit)
compose.yaml
services:
windows:
image: dockurr/windows:6.06
container_name: windows-ws
environment:
VERSION: "10l" # Windows 10 LTSC
RAM_SIZE: "3G"
CPU_CORES: "2"
DISK_SIZE: "64G"
USERNAME: ${WIN_USER}
PASSWORD: ${WIN_PASS}
REGION: "pt-BR"
KEYBOARD: "pt-BR"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- 127.0.0.1:8006:8006 # web viewer
- ${VPN_IP}:8006:8006
- ${VPN_IP}:3389:3389/tcp # RDP
- ${VPN_IP}:3389:3389/udp
volumes:
- ./storage:/storage
- ./oem:/oem # install.bat runs at the end of the install
restart: unless-stopped
stop_grace_period: 2m
.env.example
WIN_USER=labuser
WIN_PASS=change-to-a-strong-password
VPN_IP=100.x.y.z # IP of the host's VPN/Tailscale interface
Copy .env.example to .env and edit the values. The real .env is never published.
oem/install.bat
@echo off
REM Installs the Wazuh agent and registers it with the manager
set MANAGER=WAZUH_MANAGER_IP
set LOG=C:\OEM\wazuh-install.log
echo [%date% %time%] Downloading wazuh-agent 4.14.6 >> %LOG%
powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol='Tls12'; Invoke-WebRequest -UseBasicParsing -Uri 'https://packages.wazuh.com/4.x/windows/wazuh-agent-4.14.6-1.msi' -OutFile \"$env:TEMP\wazuh-agent.msi\"" >> %LOG% 2>&1
echo [%date% %time%] Installing >> %LOG%
msiexec.exe /i "%TEMP%\wazuh-agent.msi" /q WAZUH_MANAGER="%MANAGER%" WAZUH_AGENT_NAME="windows-ws" /l*v C:\OEM\wazuh-msi.log
echo [%date% %time%] msiexec exit %ERRORLEVEL% >> %LOG%
NET START WazuhSvc >> %LOG% 2>&1
echo [%date% %time%] Done >> %LOG%
oem/install.bat runs at the end of the Windows installation. It downloads the agent and registers it with the manager. The file needs Windows line endings (CRLF).
.gitignore
.env
storage/
Bring it up
cp .env.example .env # edit the values
docker compose up -d
docker logs -f windows-ws
# watch it at http://HOST:8006
# registered agents on the manager
docker exec single-node-wazuh.manager-1 /var/ossec/bin/agent_control -l
Notes
- Ports are published only on
127.0.0.1and on the VPN interface (Tailscale), nothing is exposed to the LAN or the internet. - The agent version must be equal to or lower than the manager's.
- RAM is tight on modest hardware (Wazuh uses about 2.5 GB, the VM 3 GB). If the server gets slow, shrink the VM or the indexer heap.
- If the agent does not connect, open ports 1514 (events) and 1515 (enrollment) in the firewall, and check
C:\OEM\wazuh-install.loginside the VM.