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
Arecord and — please — anAAAArecord:Example Purpose ticker.example.orgthe public page readers visit admin.ticker.example.orgthe 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.
# 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:
# 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
- Connect Integrations such as Telegram or Mastodon.
- Read Operations for upgrades and backups.
- If something is not working, Troubleshooting lists the symptoms.
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:
- Rewrites the path.
/api/**becomes/v1/**, viastripPrefixfollowed byaddPrefix. - Sets the
Originheader 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
Originto 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.