Skip to content

Installation

This guide installs the complete Ticker stack with Docker Compose, using Traefik as reverse proxy so certificates are obtained and renewed automatically.

Requirements

  • A host with Docker and the Compose plugin.
  • Ports 80 and 443 reachable from the internet. Port 80 is required for the Let's Encrypt HTTP challenge, even though all traffic is redirected to HTTPS.
  • Two hostnames pointing at the host, each with an A record and — please — an AAAA record:

    Example Purpose
    ticker.example.org the public page readers visit
    admin.ticker.example.org the admin interface

Where is the API?

It has no hostname of its own. Everything it serves — the public endpoints, attachments and RSS feeds — is reachable under /api on both names above. You can use any names you like; only their DNS records and your .env need to agree.

1. Get the files

git clone https://github.com/systemli/ticker.git
cd ticker
cp .env.example .env

2. Configure

Edit .env. Every value below must be set — docker compose refuses to start otherwise and tells you which one is missing.

.env
# Copy to .env and fill in. `docker compose up` aborts if a required value is missing.

# --- Public hostnames -------------------------------------------------------
# Two names, each with an A record (and please an AAAA record) pointing at this
# host. The API has none of its own; it is served under /api on both.
# FRONTEND_HOST is the domain your readers visit. It must additionally be
# registered as a Website/Origin on the ticker itself, in the admin interface.
ADMIN_HOST=admin.ticker.example.org
FRONTEND_HOST=ticker.example.org

# --- Let's Encrypt ---------------------------------------------------------
ACME_EMAIL=admin@example.org

# --- JWT signing secret ----------------------------------------------------
# MANDATORY. When unset, ticker generates a random secret on every start, which
# silently logs out every admin on every restart. Generate once and keep it:
#   openssl rand -hex 32
TICKER_SECRET=

# --- Database --------------------------------------------------------------
# MANDATORY. Note: a literal $ must be written as $$ (Compose interpolates).
POSTGRES_PASSWORD=

# --- Image tags ------------------------------------------------------------
# Pin these to a release for production instead of tracking latest.
TICKER_TAG=latest
ADMIN_TAG=latest
FRONTEND_TAG=latest

Generate the JWT secret once and keep it:

openssl rand -hex 32

Always set TICKER_SECRET

Without it the API generates a new random secret every time it starts. Every admin is silently logged out on each restart, upgrade, or crash. This is the single most common misconfiguration.

3. Start

docker compose up -d

Watch Traefik obtain the certificates:

docker compose logs -f traefik

Then check that the API is alive:

curl https://ticker.example.org/healthz
# OK

The stack that is started looks like this:

compose.yaml
# Production stack for the Systemli Ticker.
#
# Documentation: https://systemli.github.io/ticker/
#
#   cp .env.example .env   # then fill it in
#   docker compose up -d
#
# Traefik owns all routing. The admin and frontend images are used exactly as
# published: they contain a relative "/api" base URL, and Traefik rewrites
# /api/** to /v1/** on their own hostnames. It also injects the Origin header,
# which the API needs in order to know which ticker a request belongs to --
# browsers omit Origin on same-origin GET requests.
#
# The API has no hostname of its own. Attachments are served below /v1, so they
# arrive through the same /api path as every other request.

name: ticker

services:
  traefik:
    image: traefik:v3.7
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    command:
      - --log.level=INFO
      - --accesslog=true
      - --api.dashboard=false
      - --ping=true
      - --providers.docker=true
      - --providers.docker.exposedByDefault=false
      - --providers.docker.network=ticker-proxy
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --entrypoints.websecure.asDefault=true
      # The realtime endpoint (/v1/ws) holds connections open and pings every
      # 54s. The default 60s read timeout leaves almost no margin, so disable it.
      - --entrypoints.websecure.transport.respondingTimeouts.readTimeout=0
      - --certificatesresolvers.le.acme.email=${ACME_EMAIL:?set ACME_EMAIL in .env}
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.httpchallenge=true
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      # Read-only, but still root-equivalent on the host. See the Security
      # section of the installation guide for how to put a socket proxy here.
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-acme:/letsencrypt
    networks:
      - proxy
    healthcheck:
      test: ["CMD", "traefik", "healthcheck", "--ping"]
      interval: 30s
      timeout: 5s
      retries: 3

  # One-shot: the ticker image is built FROM scratch and runs as uid 10001.
  # Docker creates a fresh named volume owned by root, so without this the
  # uploads directory is not writable and every upload fails.
  ticker-init:
    image: alpine:3.24
    user: root
    command: ["sh", "-c", "mkdir -p /data/uploads && chown -R 10001 /data && chmod -R u+rwX /data"]
    volumes:
      - ticker-data:/data
    restart: "no"

  ticker:
    image: systemli/ticker:${TICKER_TAG:-latest}
    # The released image is currently built for amd64 only.
    platform: linux/amd64
    restart: unless-stopped
    read_only: true
    security_opt:
      - no-new-privileges:true
    depends_on:
      postgres:
        condition: service_healthy
      ticker-init:
        condition: service_completed_successfully
    environment:
      TICKER_LISTEN: ":8080"
      TICKER_METRICS_LISTEN: ":8181"
      TICKER_LOG_LEVEL: "info"
      TICKER_LOG_FORMAT: "json"
      TICKER_SECRET: ${TICKER_SECRET:?set TICKER_SECRET in .env (openssl rand -hex 32)}
      # SQLite is not an option here: the released binary is built without cgo,
      # so its SQLite driver is a non-functional stub.
      TICKER_DATABASE_TYPE: "postgres"
      TICKER_DATABASE_DSN: "host=postgres port=5432 user=ticker password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} dbname=ticker sslmode=disable TimeZone=UTC"
      TICKER_UPLOAD_PATH: "/data/uploads"
    volumes:
      - ticker-data:/data
    networks:
      # The default route must be the internet-facing network; the integrations
      # need outbound access to Telegram, Mastodon, Bluesky and Signal.
      proxy:
        priority: 100
      internal:
        priority: 50
    labels:
      - traefik.enable=true
      - traefik.docker.network=ticker-proxy
      - traefik.http.services.ticker.loadbalancer.server.port=8080
      # The image has no shell, so it cannot carry a container HEALTHCHECK.
      # Traefik can poll the endpoint from outside instead.
      - traefik.http.services.ticker.loadbalancer.healthcheck.path=/healthz
      - traefik.http.services.ticker.loadbalancer.healthcheck.interval=30s

      # --- /healthz, for uptime monitoring ---
      # It lives outside /v1, so the /api rewrite below cannot reach it. Without
      # its own router the frontend's SPA fallback would answer with index.html
      # and a monitor would report healthy no matter what.
      - traefik.http.routers.ticker-health.rule=Host(`${FRONTEND_HOST}`) && Path(`/healthz`)
      - traefik.http.routers.ticker-health.priority=100
      - traefik.http.routers.ticker-health.entrypoints=websecure
      - traefik.http.routers.ticker-health.tls.certresolver=le
      - traefik.http.routers.ticker-health.service=ticker

      # --- Shared /api/** -> /v1/** rewrite, applied in this order ---
      - traefik.http.middlewares.api-strip.stripprefix.prefixes=/api
      - traefik.http.middlewares.api-v1.addprefix.prefix=/v1

      # --- /api on the public frontend host ---
      # Origin must be injected: the API resolves which ticker to serve from it,
      # and a same-origin GET fetch() does not send one.
      - "traefik.http.middlewares.frontend-origin.headers.customrequestheaders.Origin=https://${FRONTEND_HOST}"
      - traefik.http.routers.frontend-api.rule=Host(`${FRONTEND_HOST}`) && PathPrefix(`/api/`)
      - traefik.http.routers.frontend-api.priority=100
      - traefik.http.routers.frontend-api.entrypoints=websecure
      - traefik.http.routers.frontend-api.tls.certresolver=le
      - traefik.http.routers.frontend-api.service=ticker
      - traefik.http.routers.frontend-api.middlewares=api-strip,api-v1,frontend-origin

      # --- /api on the admin host ---
      - "traefik.http.middlewares.admin-origin.headers.customrequestheaders.Origin=https://${ADMIN_HOST}"
      - traefik.http.routers.admin-api.rule=Host(`${ADMIN_HOST}`) && PathPrefix(`/api/`)
      - traefik.http.routers.admin-api.priority=100
      - traefik.http.routers.admin-api.entrypoints=websecure
      - traefik.http.routers.admin-api.tls.certresolver=le
      - traefik.http.routers.admin-api.service=ticker
      - traefik.http.routers.admin-api.middlewares=api-strip,api-v1,admin-origin

  admin:
    image: systemli/ticker-admin:${ADMIN_TAG:-latest}
    restart: unless-stopped
    depends_on:
      - ticker
    environment:
      # The image renders its nginx config at start and proxies /api itself. In
      # this stack Traefik gets there first, but nginx refuses to start without
      # the value.
      TICKER_API_URL: "http://ticker:8080/v1"
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=ticker-proxy
      - traefik.http.routers.admin.rule=Host(`${ADMIN_HOST:?set ADMIN_HOST in .env}`)
      - traefik.http.routers.admin.entrypoints=websecure
      - traefik.http.routers.admin.tls.certresolver=le
      - traefik.http.services.admin.loadbalancer.server.port=80

  frontend:
    image: systemli/ticker-frontend:${FRONTEND_TAG:-latest}
    restart: unless-stopped
    depends_on:
      - ticker
    environment:
      TICKER_API_URL: "http://ticker:8080/v1"
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=ticker-proxy
      - traefik.http.routers.frontend.rule=Host(`${FRONTEND_HOST:?set FRONTEND_HOST in .env}`)
      - traefik.http.routers.frontend.entrypoints=websecure
      - traefik.http.routers.frontend.tls.certresolver=le
      - traefik.http.services.frontend.loadbalancer.server.port=80

  postgres:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ticker
      POSTGRES_USER: ticker
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ticker -d ticker"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - internal

networks:
  proxy:
    name: ticker-proxy
  internal:
    name: ticker-internal
    internal: true

volumes:
  ticker-data:
  postgres-data:
  traefik-acme:

4. Create the first user

There is no default account and no generated password. Create a super admin explicitly:

docker compose run --rm ticker user create \
  --email admin@example.org --super-admin

Omit --password and one is generated and printed:

Created user 1
Password: 6mXq...

Copy it now — it is not stored anywhere in readable form.

--super-admin is required for the first account. Only super admins can create tickers or change integration settings.

Log in at https://admin.ticker.example.org and change the password.

5. Create a ticker and register its origin

In the admin interface, create a ticker. Then open its Websites configuration and add the public address of your frontend, exactly:

https://ticker.example.org

This step is what makes the public page work

The API decides which ticker to serve from the browser's Origin header, and it compares the value literally against the origins you registered. Use scheme and host only:

  • :white_check_mark: https://ticker.example.org
  • :x: https://ticker.example.org/ — a trailing slash never matches
  • :x: https://ticker.example.org/live — no paths
  • :x: http://... when the site is served over HTTPS

If it does not match, the frontend shows "The ticker is currently inactive" rather than an error. Add one entry per domain if the same ticker is served on several.

Finally mark the ticker active, otherwise the same inactive page is shown.

6. Verify end to end

# Through the frontend, exactly as a browser does it
curl -s https://ticker.example.org/api/init | jq .data.ticker

# Supplying the origin yourself, the way an RSS reader has to
curl -s 'https://ticker.example.org/api/init?origin=https://ticker.example.org' | jq .data.ticker

Both must return your ticker rather than null.

Now open the frontend, post a message from the admin interface, and confirm it appears without reloading the page — that proves the realtime WebSocket connection works. Upload an image to a message and confirm it renders in the frontend and in the admin interface — attachment URLs are relative, so that proves both hostnames serve /api correctly.

Next steps

How the routing works

You do not need this to run the stack, but it helps when adapting it.

The published admin and frontend images contain a relative API base URL, /api. They are used unmodified, and Traefik does two things for the /api/ path on each of their hostnames:

  1. Rewrites the path. /api/** becomes /v1/**, via stripPrefix followed by addPrefix.
  2. Sets the Origin header to that hostname.

Step 2 is not cosmetic. Browsers do not send an Origin header on same-origin GET requests, so without it the API cannot tell which ticker is being requested and returns the inactive page for every visitor. It would also collapse its response cache into a single shared entry across all tickers.

Everything the API serves lives below /v1, attachments at /v1/media/... included, so the same two steps cover images and feeds. The one exception is /healthz, which sits at the root and gets its own small router on the frontend hostname — without it the frontend's single-page fallback would answer /healthz with index.html and an uptime monitor would report healthy no matter what.

Using a different reverse proxy

Any proxy works, provided it does all of the following for /api/ on the admin and frontend hostnames:

  • rewrites /api/** to /v1/**;
  • sets Origin to the public origin of that hostname;
  • forwards WebSocket upgrades (Connection, Upgrade, HTTP/1.1) for /api/ws;
  • and allows request bodies of at least 10 MB, which is the API's own limit.

Attachments and feeds need no separate rule; they are below /v1 like everything else.

An nginx equivalent of the /api/ block:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    client_max_body_size 10m;

    location /api/ {
        proxy_pass http://ticker:8080/v1/;
        proxy_set_header Origin $scheme://$http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Hardening

The stack is deliberately minimal. Two things are worth adding for a public deployment:

Restrict the admin interface. It is reachable by anyone who knows the hostname. Traefik can limit it by IP:

labels:
  - traefik.http.middlewares.admin-allow.ipallowlist.sourcerange=203.0.113.0/24
  - traefik.http.routers.admin.middlewares=admin-allow

Note this protects the admin interface only; add the same middleware to the admin-api router to cover its /api path as well. The frontend hostname must stay public — that is where readers, RSS clients and attachments arrive.

Avoid mounting the Docker socket directly. Traefik reads it to discover containers, and even mounted read-only it is equivalent to root on the host. In a more sensitive setup, put a socket proxy in front of it and expose only container listings.