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):
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:
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:
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.