Skip to content

Self-hosting Periscope with Docker Compose

Periscope is distributed as a public container image at public.ecr.aws/x5l1y7s0/periscope.

These instructions run it with Docker Compose: first a quick look with the bundled sample inventory, then your own devices, then a production setup with a TLS-terminating reverse proxy — Caddy or Traefik — in front of it.

Quick start: the bundled sample inventory

To take a look at the interface without configuring anything, run the image directly. It boots serving a fictional sample inventory (RFC 5737 addresses — no device is reachable, but the whole UI works):

docker run --rm -p 4000:4000 -e PHX_HOST=localhost \
  public.ecr.aws/x5l1y7s0/periscope:latest

PHX_HOST tells the app which hostname browsers reach it through; without it, the page renders but its WebSocket connections are rejected and the interface stays inert.

Then open http://localhost:4000.

If a pull fails with toomanyrequests

Anonymous pulls from ECR Public are capped at 1 per second and 500 GB per month. Both quotas belong to the puller rather than the repository, so nothing on our side can raise them — and a multi-layer docker pull can cross 1/sec on its own, more easily behind a campus NAT where many users share one egress address. Authenticating with any AWS account raises the rate to 10 per second, adjustable further from that account:

aws ecr-public get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin public.ecr.aws

The login applies to every pull below as well.

Setting up your own deployment

Create a directory for the deployment and copy this compose.yaml into it:

services:
  periscope:
    image: public.ecr.aws/x5l1y7s0/periscope:${PERISCOPE_VERSION:-latest}
    ports:
      # Host port to publish on the left; the app listens on 4000 inside.
      - "${PERISCOPE_PORT:-4000}:4000"
    environment:
      # The hostname browsers reach the app through. WebSocket connections
      # from any other origin are rejected, so set this to the real hostname
      # when deploying beyond localhost.
      PHX_HOST: ${PHX_HOST:-localhost}
      # The mounted inventory below.
      PERISCOPE_CONFIG_PATH: /etc/periscope/periscope.yaml
      # Global SSH credentials for the devices in periscope.yaml. Optional at
      # boot (an empty value counts as unset); command execution fails without
      # them unless every device names its own variables via username_env/
      # password_env — each variable named that way must also be passed here.
      DEVICE_USERNAME: ${DEVICE_USERNAME:-}
      DEVICE_PASSWORD: ${DEVICE_PASSWORD:-}
      # Optional: signs browser session cookies. When unset, a random key is
      # generated at boot (with a logged warning) and sessions reset on every
      # restart. Generate one with: openssl rand -base64 48
      SECRET_KEY_BASE: ${SECRET_KEY_BASE:-}
      # Optional tuning knobs — see "Environment variables" below:
      # UI_ENABLED: "false"             # API/MCP only, no web UI
      # LG_READ_TIMEOUT: "300"          # Netmiko read timeout, seconds
      # LG_MAX_CONCURRENT_EXECUTIONS: "10"
      # MCP_SERVER_TIMEOUT: "180"       # MCP transport timeout, seconds
    volumes:
      # Your device inventory. `create_host_path: false` makes a missing
      # ./periscope.yaml a clear Compose error instead of Docker silently
      # creating a directory in its place and failing at boot.
      - type: bind
        source: ./periscope.yaml
        target: /etc/periscope/periscope.yaml
        read_only: true
        bind:
          create_host_path: false
    restart: unless-stopped
    healthcheck:
      # python3 is already in the image (the diagnostic commands run under
      # it), so the check adds no packages. urlopen raises on any non-2xx/3xx
      # response, exiting non-zero and failing the check.
      test:
        [
          "CMD",
          "python3",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4000/health', timeout=5)",
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

The latest tag moves with every release. To pin a version, set PERISCOPE_VERSION in .env (see below) to a release tag from the gallery page.

Write your periscope.yaml

periscope.yaml describes the deployment: the devices Periscope can run diagnostics against, plus optional branding. The image ships an annotated template; extract it next to your compose.yaml (plain docker run — a docker compose run would try to mount the periscope.yaml that does not exist yet):

docker run --rm --entrypoint cat \
  public.ecr.aws/x5l1y7s0/periscope:latest \
  /app/periscope.yaml.example > periscope.yaml

Then edit it to describe your inventory. A minimal example:

# Human-facing name shown in the UI (navbar brand, browser-title suffix,
# splash page). Defaults to "Periscope" when omitted.
application_name: "Example Network Console"

# Optional: a URL for your documentation site, linked from the footer.
# Omit it and the footer shows only the product attribution.
documentation_url: "https://docs.example.net/periscope"

devices:
  - id: "core1.example.net"
    host: "192.0.2.10"
    platform: "cisco_xr"
    location: "Example City, ST"
    type: "Core Router"

  - id: "sw1.example.net"
    host: "192.0.2.20"
    platform: "arista_eos"
    location: "Example City, ST"
    type: "Switch"

  # A device can name its own credential environment variables instead of
  # the global DEVICE_USERNAME/DEVICE_PASSWORD. The value is the *name* of
  # an environment variable, never the secret itself. Any variable named
  # this way must also be passed into the container (add it under
  # `environment:` in compose.yaml), and boot aborts if it is unset.
  - id: "core1.partner.net"
    host: "192.0.2.30"
    platform: "cisco_xr"
    location: "Partner City, ST"
    type: "Core Router"
    username_env: "PARTNER_USERNAME"
    password_env: "PARTNER_PASSWORD"

Every device needs id, host, platform (the Netmiko platform string), location, and type. Leave commands: and filters: out to inherit Periscope's default command and filter sets, including improvements that arrive with image upgrades; an explicit section replaces the corresponding default set entirely. The file is validated at boot — unknown keys, duplicate ids, or unsupported platforms abort startup with an error naming the offending entry.

Default commands and filters

Periscope is built with support for cisco_xr and arista_eos devices and inherits these commands: and filters: when its periscope.yaml omits those sections the sets shipped in image tag 0.1.0. Later image versions can add to these; check priv/default_commands.yaml and priv/default_filters.yaml inside a given image tag for what it actually ships:

docker run --rm --entrypoint sh public.ecr.aws/x5l1y7s0/periscope:<tag> \
  -c 'cat lib/periscope-*/priv/default_commands.yaml \
      lib/periscope-*/priv/default_filters.yaml'
Default commands: and filters:

Commands:

Command Description
ping Ping from router to supplied destination
traceroute Traceroute from router to supplied destination
show bfd Show bfd information
show bgp Show BGP info
show cef Show Cisco Express Forwarding
show controllers Show controller stats
show interfaces Show interface stats
show ipv4 interface IPv4 interface status and configuration
show ipv6 interface IPv6 interface status and configuration
show isis Show IS-IS routing information
show l2vpn xconnect L2VPN xconnect information
show l2vpn bridge-domain group L2VPN bridge domain (VPLS) information
show lacp LACP information
show lldp neighbors LLDP neighbors
show policy-map interface QoS service policy applied to an interface
show qos interface QoS interface-level configuration
show route IP routing table
show version Show router firmware version
show vrf all Show VRF information

Filters:

Filter Hidden alias of Notes
include — Keep only matching lines
inc include Short alias, accepted but not listed in the UI
i include Short alias, accepted but not listed in the UI
exclude — Drop matching lines
exc exclude Short alias, accepted but not listed in the UI
e exclude Short alias, accepted but not listed in the UI

Commands:

Command Description
ping Ping from router to supplied destination
traceroute Traceroute from router to supplied destination
show bfd Show bfd information
show bgp Show BGP info
show interfaces Show interface stats
show isis Show IS-IS routing information
show lacp LACP information
show lldp neighbors LLDP neighbors
show policy-map interface QoS service policy applied to an interface
show qos interface QoS interface-level configuration
show route IP routing table
show version Show router firmware version
show vrf all Show VRF information
show vxlan Show VXLAN information

Filters:

Filter Notes
include Keep only matching lines
exclude Drop matching lines

Supporting other commands:, filters: or vendor devices

Periscope's built-in command set is written for cisco_xr and arista_eos and devices on those platforms work with no commands: section at all, as in the minimal example above. Any other Netmiko platform, Juniper Junos, Nokia SR OS, MikroTik RouterOS, and so on has a different CLI syntax and needs its own commands: entries naming the platform explicitly before Periscope can run anything against it.

commands: is defined once at the top level of periscope.yaml, not per-device, so adding it replaces the entire built-in command set for every device in the file including cisco_xr and arista_eos devices that worked without in the minimal example. Once any device needs a custom entry, every platform in the inventory needs its commands written out explicitly; there's no partial override.

Multi vendor example: periscope.yaml with Cisco, Arista, Juniper, Nokia, and MikroTik devices
devices:
  - id: "core1.example.net"
    host: "192.0.2.10"
    platform: "cisco_xr"
    location: "Example City, ST"
    type: "Core Router"

  - id: "sw1.example.net"
    host: "192.0.2.20"
    platform: "arista_eos"
    location: "Example City, ST"
    type: "Switch"

  - id: "core2.example.net"
    host: "192.0.2.11"
    platform: "juniper_junos"
    location: "Example City, ST"
    type: "Core Router"

  - id: "core3.example.net"
    host: "192.0.2.12"
    platform: "nokia_sros"
    location: "Example City, ST"
    type: "Core Router"

  - id: "mikrotik.example.net"
    host: "192.168.0.1"
    platform: "mikrotik_routeros"
    location: "City, ST"
    type: "Edge Router"

commands:
  # --- Cisco IOS XR (cisco_xr) ---
  - name: "ping"
    help: "Ping from router to supplied destination"
    platforms: ["cisco_xr"]

  - name: "traceroute"
    help: "Traceroute from router to supplied destination"
    platforms: ["cisco_xr"]

  - name: "show route ipv4"
    help: "IPv4 routing table"
    platforms: ["cisco_xr"]

  - name: "show route ipv6"
    help: "IPv6 routing table"
    platforms: ["cisco_xr"]

  - name: "show interfaces brief"
    help: "Interface status"
    platforms: ["cisco_xr"]

  - name: "show version"
    help: "System resource usage and IOS XR version"
    platforms: ["cisco_xr"]

  # --- Arista EOS (arista_eos) ---
  - name: "ping"
    help: "Ping from switch to supplied destination"
    platforms: ["arista_eos"]

  - name: "traceroute"
    help: "Traceroute from switch to supplied destination"
    platforms: ["arista_eos"]

  - name: "show ip route"
    help: "IPv4 routing table"
    platforms: ["arista_eos"]

  - name: "show ipv6 route"
    help: "IPv6 routing table"
    platforms: ["arista_eos"]

  - name: "show interfaces status"
    help: "Interface status"
    platforms: ["arista_eos"]

  - name: "show version"
    help: "System resource usage and EOS version"
    platforms: ["arista_eos"]

  # --- Juniper Junos (juniper_junos) ---
  - name: "ping count 4"
    help: "Ping from router to supplied destination"
    platforms: ["juniper_junos"]

  - name: "traceroute"
    help: "Traceroute from router to supplied destination"
    platforms: ["juniper_junos"]

  - name: "show route"
    help: "IPv4 routing table"
    platforms: ["juniper_junos"]

  - name: "show route table inet6.0"
    help: "IPv6 routing table"
    platforms: ["juniper_junos"]

  - name: "show interfaces terse"
    help: "Interface status and configured addresses"
    platforms: ["juniper_junos"]

  - name: "show version"
    help: "System resource usage and Junos version"
    platforms: ["juniper_junos"]

  # --- Nokia SR OS (nokia_sros) ---
  - name: "ping"
    help: "Ping from router to supplied destination"
    platforms: ["nokia_sros"]

  - name: "traceroute"
    help: "Traceroute from router to supplied destination"
    platforms: ["nokia_sros"]

  - name: "show router route-table"
    help: "IPv4 routing table"
    platforms: ["nokia_sros"]

  - name: "show router route-table ipv6"
    help: "IPv6 routing table"
    platforms: ["nokia_sros"]

  - name: "show port"
    help: "Interface status"
    platforms: ["nokia_sros"]

  - name: "show router interface"
    help: "IPv4 addresses configured on interfaces"
    platforms: ["nokia_sros"]

  - name: "show system information"
    help: "System resource usage and SR OS version"
    platforms: ["nokia_sros"]

  # --- MikroTik RouterOS (mikrotik_routeros) ---
  - name: "/tool/ping count=4"
    help: "Ping from router to supplied destination"
    platforms: ["mikrotik_routeros"]

  - name: "/tool/traceroute count=1"
    help: "Traceroute from router to supplied destination"
    platforms: ["mikrotik_routeros"]

  - name: "/ip route print"
    help: "IPv4 routing table"
    platforms: ["mikrotik_routeros"]

  - name: "/ipv6 route print"
    help: "IPv6 routing table"
    platforms: ["mikrotik_routeros"]

  - name: "/interface print"
    help: "Interface status"
    platforms: ["mikrotik_routeros"]

  - name: "/ip address print"
    help: "IPv4 addresses configured on interfaces"
    platforms: ["mikrotik_routeros"]

  - name: "/system resource print"
    help: "System resource usage and RouterOS version"
    platforms: ["mikrotik_routeros"]

Tip

Drop any one of those five platforms blocks and devices on that platform keep their platform: value and stay in the UI, but the command list against them goes empty commands: only replaces what it names, it doesn't fall back to the built-in set for the platforms it leaves out.

Set the environment variables

The compose.yaml above reads its variables from a .env file in the same directory (Docker Compose loads it automatically). Create one:

# .env — next to compose.yaml. Keep it out of version control.
DEVICE_USERNAME=lookingglass
DEVICE_PASSWORD=change-me
SECRET_KEY_BASE=<output of: openssl rand -base64 48>
# Set to the real hostname when deploying beyond localhost — a browser at
# any other hostname gets a rendered but dead UI (see the table below).
# PHX_HOST=periscope.example.net
# PERISCOPE_VERSION=1.2.3
# PERISCOPE_PORT=4000

What each variable does:

Variable Required? Purpose
PHX_HOST Yes, beyond localhost The hostname browsers reach the app through. WebSocket connections from any other origin are rejected, so a wrong value renders the UI but leaves it dead. For those curious about the underlying architecture, Periscope is built with Phoenix.
DEVICE_USERNAME / DEVICE_PASSWORD Yes, to run commands Global SSH credentials for the devices in periscope.yaml. Optional at boot (an empty value counts as unset), but command execution fails without them unless every device names its own variables via username_env/password_env.
SECRET_KEY_BASE Recommended Signs browser session cookies. When unset, a random key is generated at each boot (with a logged warning) and sessions reset on every restart. Generate one with openssl rand -base64 48.
PERISCOPE_VERSION No Image tag to run; defaults to latest. Pin a release tag for reproducible deploys.
PERISCOPE_PORT No Host port to publish; defaults to 4000.
UI_ENABLED No Set false to disable the web UI and serve only the REST API and MCP endpoint.
LG_READ_TIMEOUT No Netmiko read timeout in seconds for a device execution (default 30, ceiling 3600).
LG_MAX_CONCURRENT_EXECUTIONS No Maximum device executions running at once across all channels (default 10, ceiling 100). Each execution runs a Python/Netmiko interpreter of roughly 26 MiB, so this bounds worst-case memory.
MCP_SERVER_TIMEOUT No MCP transport request timeout in seconds (default 180). Should exceed the device ceiling (LG_READ_TIMEOUT + connect budget), or slow devices time out at the transport before their error reaches the client — the app warns at boot if it does not.

Any per-device credential variables your periscope.yaml names via username_env/password_env (like PARTNER_USERNAME above) must also be added to the .env file and passed through in the environment: block of compose.yaml — Compose does not forward host variables into a container unless they are listed there.

Then start it:

docker compose up -d

Open http://localhost:4000 (or the host and port you configured). Logs are available with docker compose logs -f periscope; the first log line names the configuration file the app loaded, which is the first thing to check if you see the fictional sample inventory instead of your own.

Production: Caddy as a TLS-terminating reverse proxy

For a deployment reachable at a real hostname, put Caddy in front of Periscope. Caddy obtains and renews Let's Encrypt certificates automatically and proxies both HTTP and the LiveView WebSocket with no extra configuration.

compose.yaml:

services:
  periscope:
    image: public.ecr.aws/x5l1y7s0/periscope:${PERISCOPE_VERSION:-latest}
    # No `ports:` block — only Caddy is reachable from outside; it talks to
    # Periscope over the Compose network on port 4000.
    environment:
      # Must match the site address in the Caddyfile: Phoenix rejects
      # WebSocket connections from any other origin.
      PHX_HOST: ${PHX_HOST:?set PHX_HOST to the public hostname}
      PERISCOPE_CONFIG_PATH: /etc/periscope/periscope.yaml
      DEVICE_USERNAME: ${DEVICE_USERNAME:-}
      DEVICE_PASSWORD: ${DEVICE_PASSWORD:-}
      SECRET_KEY_BASE: ${SECRET_KEY_BASE:-}
    volumes:
      - type: bind
        source: ./periscope.yaml
        target: /etc/periscope/periscope.yaml
        read_only: true
        bind:
          create_host_path: false
    restart: unless-stopped
    healthcheck:
      test:
        [
          "CMD",
          "python3",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4000/health', timeout=5)",
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
      # HTTP/3
      - "443:443/udp"
    environment:
      PHX_HOST: ${PHX_HOST:?set PHX_HOST to the public hostname}
    volumes:
      - type: bind
        source: ./Caddyfile
        target: /etc/caddy/Caddyfile
        read_only: true
        bind:
          create_host_path: false
      # Certificates and ACME account state — must persist across restarts,
      # or Caddy re-requests certificates and can hit Let's Encrypt rate
      # limits.
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped
    depends_on:
      periscope:
        condition: service_healthy

volumes:
  caddy_data:
  caddy_config:

Caddyfile, next to it:

{$PHX_HOST} {
    reverse_proxy periscope:4000
}

That is the whole proxy configuration. Caddy fills in the rest: it redirects HTTP to HTTPS, provisions a certificate for the hostname, and upgrades WebSocket connections transparently. Browsers connect through it with an Origin of https://<PHX_HOST>, which is exactly what Phoenix's WebSocket origin check expects.

Set PHX_HOST in .env to the public hostname — it feeds both the Caddyfile's site address and Phoenix's origin check, so the two can never drift apart:

PHX_HOST=periscope.example.net
DEVICE_USERNAME=lookingglass
DEVICE_PASSWORD=change-me
SECRET_KEY_BASE=<output of: openssl rand -base64 48>

Requirements before docker compose up -d:

  • DNS for the hostname points at this machine.
  • Ports 80 and 443 are open and not held by another web server (Caddy needs 80 for the ACME HTTP challenge and the HTTPS redirect).

Then browse to https://periscope.example.net. Certificate issuance happens on the first request and takes a few seconds; docker compose logs -f caddy shows its progress.