Index Monitor appliance install guide

This guide takes you from a purchased Index Monitor appliance license to a running self-hosted appliance on a Linux server with Docker.

The install release is appliance-v1.1.5. The published GHCR images for that release are pinned below to v1.1.5; do not use latest.

Prerequisites

Before you start, have these ready:

Hardware sizing / small deployments

The Index Monitor appliance is an API poller plus MariaDB. Google does the crawling, indexing, and Search Console processing; the appliance stores the results and serves the UI. There is no crawler and no headless browser in the GSC profile.

Container footprint

Service Typical memory footprint Notes
db About 250-400 MB on the small preset MariaDB stores Search Console rows. The small preset caps innodb_buffer_pool_size at 128 MB.
app About 30-60 MB per PHP-FPM child The default pool allows 20 children for a busy agency. The small preset lowers this to 4 children, roughly 150-300 MB of PHP-FPM budget.
worker + cron About 100-200 MB combined The Python workers are async I/O (aiohttp / aiomysql) and spend most of their time waiting on Google APIs or the database.

Hardware recommendations

Target Recommendation Notes
Bare minimum VPS 2 vCPU / 2 GB RAM Use the small preset below and add swap. Suitable for one operator and a few properties.
Comfortable small box 2-4 cores / 4 GB RAM Recommended starting point for a single agency. Leaves room for backfills, reports, and database cache.
Old laptop or mini-PC 2+ cores / 4 GB+ RAM, SSD Usually the easiest "hardware you already own" option. x86-64 images work unchanged.
Small VPS 2 vCPU / 4 GB RAM A good hosted baseline if you do not want to keep hardware on site.

The current appliance images are published for linux/amd64 only. Use an x86-64 Linux host for this release.

Small-box preset

For a 2 GB target, you can optimize MariaDB and PHP-FPM memory limits.

Create mariadb-small.cnf in your install directory:

[mariadb]
innodb_buffer_pool_size=128M
max_connections=80
performance_schema=OFF

Then, in your docker-compose.yml file (created below): 1. Add the PHP-FPM variables to your x-app-env: section. 2. Mount the .cnf file in the db service volumes.

x-app-env: &app-env
  # ... existing env vars ...
  SC_PHP_FPM_MAX_CHILDREN: "4"
  SC_PHP_FPM_START_SERVERS: "2"
  SC_PHP_FPM_MIN_SPARE_SERVERS: "1"
  SC_PHP_FPM_MAX_SPARE_SERVERS: "2"
  SC_PHP_FPM_MAX_REQUESTS: "500"

services:
  db:
    # ...
    volumes:
      - appliance_db:/var/lib/mysql
      - ./mariadb-small.cnf:/etc/mysql/conf.d/90-small.cnf:ro

On 2 GB hosts, add a swap file before first boot so a backfill or database warm-up does not trigger the kernel OOM killer:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

The Sentinel appliance has an optional JS-rendering worker profile that runs headless Chromium. The Index Monitor appliance does not use that worker.

Create the install directory

mkdir -p /opt/gsc-appliance/custom
cd /opt/gsc-appliance

The custom directory is mounted into the app container. You can leave it empty on a standard install.

Pull the pinned images

This guide assumes public GHCR images:

docker pull ghcr.io/user256/portal-appliance-app:v1.1.5
docker pull ghcr.io/user256/portal-appliance-worker:v1.1.5

The image tag for release appliance-v1.1.5 is v1.1.5.

This is the intended customer path: public GHCR images after the the original 1.0.0 appliance-release retraction described in appliance-build-and-distribution.md. If the packages are still private during staging, authenticated GHCR pulls are the temporary fallback:

docker login ghcr.io
docker pull ghcr.io/user256/portal-appliance-app:v1.1.5
docker pull ghcr.io/user256/portal-appliance-worker:v1.1.5

Create docker-compose.yml

The easiest way to generate your configuration is using our Web Generator, which will output a fully-populated compose file with secure database passwords and your domain already set.

Launch the Install Generator

If you prefer to create it manually, use this single-file template. It uses a YAML anchor (x-app-env) to inject environment variables into all application containers without requiring a separate .env file.

x-app-env: &app-env
  # Database
  SC_DB_DRIVER: "mysql"
  SC_DB_NAME: "portal"
  SC_DB_USER: "portal"
  SC_DB_PASS: "replace-with-a-strong-database-password"

  # Configuration
  SC_PUBLIC_SITE_BASE_URL: "https://gsc.example.com"
  SC_SITE_PROFILE: "gsc_appliance"
  APPLIANCE_HOSTNAME: "gsc-appliance"

  # Initial Admin (Change this later)
  ADMIN_EMAIL: "admin@example.com"
  ADMIN_PASSWORD: "replace-with-a-temporary-strong-password"

  # Mail Settings
  # WARNING: alerts do not reach an inbox in log mode. Configure a delivery
  # provider in the first-boot wizard before relying on alert email.
  SC_EMAIL_TRANSPORT: "log"
  SC_FROM_ADDRESS: "noreply@example.com"
  SC_FROM_NAME: "Index Monitor"
  SC_ADMIN_EMAIL: "admin@example.com"

  # License Details — copy all three values from the vendor license handoff.
  # Do not use a sample or placeholder public key: it must match the active
  # Ed25519 signing key or this appliance cannot verify its Pro license.
  SC_LICENSE_SERVER_URL: "https://analytics.johnfegan.org"
  SC_LICENSE_PUBLIC_KEY: "XW1pxiNePCWpu3x+PMB4u2oRtTl/odMU5im9+sCeQac="
  SC_LICENSE_INSTALL_KEY: "replace-with-your-install-key"

services:
  db:
    image: mariadb:10.11
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: "portal"
      MARIADB_USER: "portal"
      MARIADB_PASSWORD: "replace-with-a-strong-database-password"
      MARIADB_ROOT_PASSWORD: "replace-with-a-strong-root-password"
    volumes:
      - appliance_db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 12

  app:
    image: ghcr.io/user256/portal-appliance-app:v1.1.5
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:80"
    volumes:
      - ./custom:/var/www/portal/custom
      - appliance_license:/var/www/portal/var/cache/license
    environment:
      <<: *app-env

  worker:
    image: ghcr.io/user256/portal-appliance-worker:v1.1.5
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - appliance_license:/opt/portal/var/cache/license
    environment:
      <<: *app-env

  cron:
    image: ghcr.io/user256/portal-appliance-worker:v1.1.5
    command: ["cron"]
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - appliance_license:/opt/portal/var/cache/license
    environment:
      <<: *app-env

volumes:
  appliance_db:
  appliance_license:

Env var reference

The variables above are the minimum needed to boot. You can find more configuration options in the Configuration guide (e.g. SMTP setups).

Start the appliance

docker compose -f docker-compose.yml up -d

Watch the first boot:

docker compose -f docker-compose.yml logs -f app

Expected app boot messages include:

[appliance] waiting for the database at db:3306 ...
[appliance] database ready after Ns.
[appliance] checking schema/code version compatibility...
[appliance] applying migrations (idempotent)...
[appliance] ensuring admin user <ADMIN_EMAIL> exists...
[appliance] seeding GSC tiers (idempotent)...
[appliance] activating default theme (idempotent)...
[appliance] handing off to: /usr/local/bin/appliance-run-app.sh

Check the login page:

curl -I http://localhost:8080/account/login.php

Use your chosen APPLIANCE_HTTP_PORT if you changed it from 8080.

First-boot setup wizard

Open the appliance in your browser:

http://your-server:8080/account/login.php

If you put the appliance behind HTTPS, use your public URL instead.

Log in with ADMIN_EMAIL and ADMIN_PASSWORD from .env. The browser setup walks through these steps:

  1. Replace the bootstrap identity. Use your real operator email and a strong password. Passwords must be 12 or more characters with uppercase, lowercase, number, and symbol.
  2. Enable two-factor authentication. Scan the QR code with an authenticator app, confirm a code, and save the recovery codes.
  3. Configure notification delivery. Choose Resend, SMTP, or Gmail OAuth; set the primary sender; send a test email if you want delivery checked immediately. You can skip this step and return later, but alerts, invites, password resets, and operator notices will not send until delivery is configured.
  4. Connect Google Search Console. The wizard shows the exact authorized redirect URI. In Google Cloud, create a Web application OAuth client, add that redirect URI, enable the Search Console API, then upload the downloaded OAuth client JSON or paste the client ID and secret. Connect a Google account and choose the properties the appliance should collect.
  5. Finish setup and open Search Analytics.

GSC Analytics reuses the Google connection from Index Monitor. If you open the GSC Analytics connected-accounts page and it redirects to Index Monitor, that is expected.

Apply or update your license

The appliance reads the license install key from .env. To apply a license after first boot, edit these values:

SC_LICENSE_INSTALL_KEY=lsv_replace_with_your_install_key
SC_LICENSE_SERVER_URL=https://analytics.johnfegan.org
SC_LICENSE_PUBLIC_KEY=XW1pxiNePCWpu3x+PMB4u2oRtTl/odMU5im9+sCeQac=

Then recreate the services so app, worker, and cron all receive the same environment:

docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml restart worker cron

The cron container renews the license token at boot and then keeps it fresh. Check the cron logs:

docker compose -f docker-compose.yml logs --tail=80 cron

For a working license, expect a line like:

[license-renew] renewed OK.

The license is bound to this install's machine fingerprint. The fingerprint uses the pinned APPLIANCE_HOSTNAME and the shared license volume. Do not change APPLIANCE_HOSTNAME, delete the appliance_license volume, or move the install to another host after the first successful renewal unless your vendor rotates the install key.

Free tier and downgrade behavior

Without a valid Pro license, the appliance stays usable on the Free tier:

Worker logs may include a Free-tier cap message when no valid license token is available.

If a license is cancelled, suspended, expired, or cannot renew, the appliance does not immediately shut off. A still-valid cached token continues to work until it expires. After expiry, the appliance falls back gracefully to the Free tier and may show an operator message such as:

Appliance locked. This appliance has not been licensed yet. Contact your vendor to restore access.

or:

The license has expired and could not be renewed.

Restore the license values in .env, keep the same hostname and license volume, and restart cron and worker to renew again.

Basic troubleshooting

Show container status:

docker compose -f docker-compose.yml ps

The db service should become healthy, then app, worker, and cron should stay running.

Check app boot and web errors:

docker compose -f docker-compose.yml logs --tail=200 app

Check worker startup and tier enforcement:

docker compose -f docker-compose.yml logs --tail=200 worker

Check license renewals:

docker compose -f docker-compose.yml logs --tail=200 cron

Check the web entry point from the server:

curl -i http://localhost:8080/account/login.php

Use your configured host port if it is not 8080.

Common problems:

Symptom What to check
db is not healthy Check SC_DB_PASS, DB_ROOT_PASSWORD, and docker compose logs db.
Login page does not respond Check APPLIANCE_HTTP_PORT, local firewall rules, and docker compose logs app.
First boot loops or exits Read the [appliance] lines in app logs; schema and migration failures are reported there.
License renew says not_configured One or more SC_LICENSE_INSTALL_KEY, SC_LICENSE_SERVER_URL, or SC_LICENSE_PUBLIC_KEY values is missing from .env.
License renew returns http_403 The install key may be suspended, cancelled, already bound to a different fingerprint, or pointed at the wrong license server. Contact support.
Worker remains on Free tier after adding a license Restart cron and worker, confirm [license-renew] renewed OK., and confirm APPLIANCE_HOSTNAME has not changed.
Google OAuth callback fails Confirm SC_PUBLIC_SITE_BASE_URL and the Google authorized redirect URI match exactly, including https and the shared path /account/oauth-callback.php (for Gmail and Search Console). On upgrades from before v1.1.5, update the Google client first or Google returns redirect_uri_mismatch.
Emails do not send Complete the Delivery step in the setup wizard, or check the provider variables for the transport in .env.

Updates

For a future release, update the pinned image tags in docker-compose.yml, then run:

docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d

Keep APPLIANCE_HOSTNAME and the named volumes unchanged across updates.

Support

For license, image-access, or billing problems, use the support contact supplied with your purchase or license email.

For an already running appliance, open the in-app Support page from the account navigation. Include:

Do not send .env, passwords, install keys, OAuth secrets, or provider API keys in a support request.