Paul M. Webb

Running Signal Desktop in Docker to Automatically Screenshot a Conversation

I wanted an automatic, timestamped record of one Signal conversation without having to sit there taking screenshots myself. Signal Desktop needs a screen to run on, so I put it in a Docker container with a virtual display and a small script that takes a picture of that display every 20 minutes. It runs in the background on my Windows PC and quietly fills a folder with screenshots.

Here’s the whole setup, so you can build it yourself.

Why This Helps If You’re Being Harassed

This is especially useful if someone is harassing you on Signal with disappearing messages. Normally, to keep a record you’d have to open every message yourself and screenshot it before it vanishes. That means reading things you’d rather not see, and racing a timer. With this setup, the container keeps the conversation open and captures it for you, so you can gather a dated record without having to look at the messages as they come in.

A few things to know so you don’t miss anything:

How It Works

What You Need

Step 1: Create the Build Files

Make a new folder (for example C:\signal-docker) and save each of the five files below into it, using exactly these file names.

Dockerfile

FROM debian:12-slim

ENV DEBIAN_FRONTEND=noninteractive \
    DISPLAY=:0 \
    SCREEN_WIDTH=1280 \
    SCREEN_HEIGHT=800 \
    SCREENSHOT_INTERVAL_SECONDS=1200

# Base packages for X, VNC, noVNC, D-Bus, screenshots, fonts, and utils for healthchecks
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
      ca-certificates curl wget gnupg \
      x11vnc xvfb fluxbox novnc websockify python3-websockify \
      x11-apps imagemagick dbus xdg-utils x11-utils \
      fonts-noto-color-emoji fonts-noto-cjk; \
    rm -rf /var/lib/apt/lists/*

# Install Signal Desktop from the official APT repository
RUN set -eux; \
    mkdir -p /usr/share/keyrings /etc/apt/sources.list.d; \
    wget -O- https://updates.signal.org/desktop/apt/keys.asc | gpg --dearmor > /usr/share/keyrings/signal-desktop-keyring.gpg; \
    wget -O /etc/apt/sources.list.d/signal-desktop.sources https://updates.signal.org/static/desktop/apt/signal-desktop.sources; \
    apt-get update; \
    apt-get install -y --no-install-recommends signal-desktop; \
    rm -rf /var/lib/apt/lists/*

# Copy scripts
COPY start.sh /usr/local/bin/start.sh
COPY screenshot-loop.sh /usr/local/bin/screenshot-loop.sh
COPY healthcheck.sh /usr/local/bin/healthcheck.sh
COPY watchdog.sh /usr/local/bin/watchdog.sh

RUN chmod +x /usr/local/bin/start.sh /usr/local/bin/screenshot-loop.sh /usr/local/bin/healthcheck.sh /usr/local/bin/watchdog.sh

EXPOSE 5800 5900

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD /usr/local/bin/healthcheck.sh

CMD ["/usr/local/bin/start.sh"]

start.sh

#!/usr/bin/env bash
set -euo pipefail

export DISPLAY=${DISPLAY:-:0}
export SCREEN_WIDTH=${SCREEN_WIDTH:-1280}
export SCREEN_HEIGHT=${SCREEN_HEIGHT:-800}

# ----- Wait for outbound network/DNS to be ready -----
retries=30
until curl -fsS --connect-timeout 3 --max-time 5 https://updates.signal.org >/dev/null 2>&1; do
  retries=$((retries-1))
  if [ $retries -le 0 ]; then
    echo "[start] Network not ready after multiple attempts; proceeding anyway" >&2
    break
  fi
  echo "[start] Waiting for network..." >&2
  sleep 2
done

# ----- D-Bus: clear stale pid and start only if not running -----
mkdir -p /run/dbus || true
if pgrep -x dbus-daemon >/dev/null; then
  echo "[start] D-Bus already running"
else
  if [ -e /run/dbus/pid ] && ! kill -0 "$(cat /run/dbus/pid 2>/dev/null)" 2>/dev/null; then
    echo "[start] Removing stale /run/dbus/pid"
    rm -f /run/dbus/pid
  fi
  dbus-daemon --system --fork --print-address || true
fi

# ----- Xvfb: clear stale lock and start only if not running -----
if pgrep -x Xvfb >/dev/null; then
  echo "[start] Xvfb already running on $DISPLAY"
else
  if [ -e /tmp/.X0-lock ] && ! kill -0 "$(cat /tmp/.X0-lock 2>/dev/null)" 2>/dev/null; then
    echo "[start] Removing stale /tmp/.X0-lock"
    rm -f /tmp/.X0-lock
  fi
  Xvfb "$DISPLAY" -screen 0 ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x24 &
fi

# Wait for X to be responsive before continuing (up to ~30s)
for i in $(seq 1 30); do
  if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
    echo "[start] X display is ready"
    break
  fi
  echo "[start] Waiting for X display..."
  sleep 1
done
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
  echo "[start] X display not responding; exiting to let Docker restart" >&2
  exit 1
fi

# Window manager
if pgrep -x fluxbox >/dev/null; then
  echo "[start] fluxbox already running"
else
  fluxbox &
fi

# VNC & noVNC
if pgrep -x x11vnc >/dev/null; then
  echo "[start] x11vnc already running"
else
  x11vnc -display "$DISPLAY" -forever -shared -rfbport 5900 -nopw &
fi
if pgrep -f "websockify .* 5800" >/dev/null; then
  echo "[start] websockify already running"
else
  websockify --web=/usr/share/novnc 5800 localhost:5900 &
fi

# Screenshot loop in background (one instance)
if pgrep -f "/usr/local/bin/screenshot-loop.sh" >/dev/null; then
  echo "[start] screenshot loop already running"
else
  /usr/local/bin/screenshot-loop.sh &
fi

# Launch Signal Desktop (ensure only one instance)
if pgrep -f "signal-desktop" >/dev/null; then
  echo "[start] signal-desktop already running"
else
  signal-desktop --no-sandbox --disable-gpu --disable-software-rasterizer --use-tray-icon=false &
fi

# Start watchdog (one instance)
if pgrep -f "/usr/local/bin/watchdog.sh" >/dev/null; then
  echo "[start] watchdog already running"
else
  /usr/local/bin/watchdog.sh &
fi

# Keep container alive
wait -n || true
sleep infinity

screenshot-loop.sh

#!/usr/bin/env bash
set -euo pipefail

export DISPLAY=${DISPLAY:-:0}
mkdir -p /config/screenshots
: "${SCREENSHOT_INTERVAL_SECONDS:=1200}"

while true; do
  ts="$(date +'%Y-%m-%d_%H-%M-%S')"
  xwd -root -silent | convert xwd:- "/config/screenshots/signal_${ts}.png" || true
  sleep "${SCREENSHOT_INTERVAL_SECONDS}"
done

healthcheck.sh

#!/usr/bin/env bash
set -euo pipefail

# 1) Check noVNC is serving (200/301)
if ! curl -fsS --connect-timeout 3 --max-time 5 http://localhost:5800/ >/dev/null; then
  echo "noVNC not responding" >&2
  exit 1
fi

# 2) Check Signal process exists
if ! pgrep -f "signal-desktop" >/dev/null; then
  echo "signal-desktop process not found" >&2
  exit 1
fi

# 3) Check X display
if ! xdpyinfo -display "${DISPLAY:-:0}" >/dev/null 2>&1; then
  echo "X display not responding" >&2
  exit 1
fi

exit 0

watchdog.sh

#!/usr/bin/env bash
set -euo pipefail

export DISPLAY=${DISPLAY:-:0}

fail_count=0
max_fail_before_restart=3
max_fail_before_exit=6

while true; do
  if /usr/local/bin/healthcheck.sh >/dev/null 2>&1; then
    fail_count=0
  else
    fail_count=$((fail_count+1))
    echo "[watchdog] healthcheck failed ($fail_count)" >&2

    # Do NOT start X or dbus here; only manage the app and let restart policy handle the rest
    if [ $fail_count -eq $max_fail_before_restart ]; then
      echo "[watchdog] restarting signal-desktop" >&2
      pkill -f signal-desktop || true
      signal-desktop --no-sandbox --disable-gpu --disable-software-rasterizer --use-tray-icon=false &
    fi

    if [ $fail_count -ge $max_fail_before_exit ]; then
      echo "[watchdog] too many failures; exiting to trigger Docker restart" >&2
      exit 1
    fi
  fi

  sleep 20
done

If you save the .sh files with a Windows editor, make sure they use Linux (LF) line endings. Otherwise the container won’t be able to run them.

Step 2: Create the Output Folders

Pick where the screenshots and settings should live. I use my D: drive. In PowerShell:

mkdir D:\Signal_Screenshots -ErrorAction SilentlyContinue
mkdir D:\Signal_Config -ErrorAction SilentlyContinue

Step 3: Build the Image

In the same PowerShell window, go to the folder with the build files and run:

cd C:\signal-docker
docker build -t signal-screenshots:latest .

Step 4: Start the Container

Change the folder paths, time zone and screenshot interval to suit you, then paste this into PowerShell. The interval is in seconds, so 1200 is one screenshot every 20 minutes. If you’re capturing disappearing messages, make it shorter than their timer (see above).

docker run -d `
  --name signal `
  --restart unless-stopped `
  -p 127.0.0.1:5800:5800 `
  -e TZ=America/Chicago `
  -e SCREENSHOT_INTERVAL_SECONDS=1200 `
  -e SCREEN_WIDTH=1280 -e SCREEN_HEIGHT=800 `
  -v D:\Signal_Screenshots:/config/screenshots `
  -v D:\Signal_Config:/config `
  --log-opt max-size=10m `
  --log-opt max-file=5 `
  --shm-size=1g `
  signal-screenshots:latest

A Quick Security Note

This build doesn’t put a password on the browser viewer. Anyone who can open it can read and type in your Signal account. That’s why the command above uses -p 127.0.0.1:5800:5800, which only lets this PC reach it. Don’t change that to -p 5800:5800 unless you want everyone on your network to have access. Also keep in mind the screenshots folder will hold your actual messages, so store it somewhere private.

Step 5: Link Signal

Open http://localhost:5800/vnc.html in your browser and click Connect. You’ll see Signal Desktop showing a QR code.

On your phone, open Signal and go to Settings → Linked devices → Link new device, then scan the code. If Signal offers to transfer your message history, say yes so older messages show up too.

Open the conversation you want to keep, scroll to the bottom, and then just close the browser tab. The container keeps running in the background.

Step 6: Check It’s Working

After the first interval has passed, look in your screenshots folder. You should see files named like signal_2026-10-22_14-20-00.png. If the folder stays empty, check the container’s logs with:

docker logs signal

Stopping, Restarting and Updating

That’s it. Once it’s linked, it just runs, and you’ve got a running visual history of the conversation without lifting a finger.

This post partially created with AI