Skip to content

Frames LXC

An LXC container running two ImmichFrame instances, each exposed to the internet via its own independent Tailscale device using a Tailscale sidecar container. No Caddy or path-based routing — each frame is served at the root of its own .ts.net hostname via Tailscale Funnel.

  • papaframe → container names ts-frame1 / immich-frame1, working dir /home/jake/papaframe_v3/
  • nanaframe → container names ts-frame2 / immich-frame2, working dir /home/jake/nanaframe_v3/

Related: Immich VM

Both ImmichFrame instances connect to the Immich VM (http://192.168.0.71:2283) as their photo source. The Immich VM also runs its own ImmichFrame instances for frames on the local network — see Immich VM Part 3 for that setup. The difference: frames on the local network are served directly from the VM; frames here (papaframe, nanaframe) are physically outside the LAN and reach ImmichFrame via Tailscale Funnel.

Container name vs. hostname naming

The container names use frame1/frame2 numbering, but the Tailscale hostnames (and therefore .ts.net subdomains) are papaframe and nanaframe. This is inverted from what you might expect — ts-frame1 serves papaframe, and ts-frame2 serves nanaframe.


How it works

Each frame instance is a self-contained Docker Compose stack with two containers:

  1. Tailscale sidecar (tailscale/tailscale:latest) — registers as its own Tailscale device, terminates TLS using the auto-provisioned .ts.net cert, and exposes the instance via Funnel (public internet). Configured declaratively via serve.json.

  2. ImmichFrame (ghcr.io/immichframe/immichframe:latest) — the actual photo frame app. Uses network_mode: service:ts-frameN to share the sidecar's network namespace, which is why the sidecar can proxy to 127.0.0.1:8080 — from the sidecar's perspective, ImmichFrame IS localhost.

Because ImmichFrame shares the sidecar's network namespace, it does not define its own ports:, hostname:, or any network settings.


Directory layout (per instance)

papaframe_v3/           (or nanaframe_v3/)
├── docker-compose.yml
├── .env                ← ImmichFrame config (API key, albums, weather, etc.)
├── ts-config/
│   └── serve.json      ← Tailscale serve/funnel config
└── ts-state/           ← Created automatically; holds tailscaled persistent state

Setup (new instance)

Step 1 — Generate a Tailscale auth key

In the Tailscale admin console → Settings → Keys → Generate auth key.

Recommended options:

  • Reusable — allows registering multiple sidecar devices with one key
  • Not ephemeral — ephemeral nodes are removed from the tailnet on disconnect, which you don't want for a persistent always-on service
  • Auto-apply a tag (e.g. tag:container) — lets one ACL grant cover all container devices instead of approving each individually

Paste this key into TS_AUTHKEY in docker-compose.yml. After first successful auth the key is no longer used (state persists in ts-state/), but leaving it set is harmless.

Step 2 — Write ts-config/serve.json

The serve.json is identical for every instance — no per-instance values needed. ${TS_CERT_DOMAIN} is expanded automatically by the Tailscale container at runtime to this device's own full hostname.

ts-config/serve.json
{
  "TCP": {
    "443": { "HTTPS": true }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": { "Proxy": "http://127.0.0.1:8080" }
      }
    }
  },
  "AllowFunnel": {
    "${TS_CERT_DOMAIN}:443": true
  }
}
  • TCP.443.HTTPS — tailscaled terminates TLS on 443 using the auto-provisioned Tailscale cert. This is what makes https://papaframe.your-tailnet.ts.net work without managing certs manually.
  • ${TS_CERT_DOMAIN} — template variable expanded to this container's own full hostname. No hardcoding needed.
  • Proxy: "http://127.0.0.1:8080" — forwards traffic to ImmichFrame, which listens on 8080 and is reachable at localhost because of the shared network namespace.
  • AllowFunnel: true — exposes the service to the public internet, not just the tailnet. Set to false or omit to restrict to tailnet-only access.

Step 3 — Write docker-compose.yml

The papaframe and nanaframe compose files are the live reference. The jakeframe example below shows the naming conventions for a clean new instance — see Adding another instance for what to change.

papaframe — docker-compose.yml
services:
  # --- The Tailscale sidecar: this is what gives the instance its own
  # tailnet identity, hostname, TLS cert, and Funnel exposure. ---
  ts-frame1:
    image: tailscale/tailscale:latest
    container_name: ts-frame1         # Must be unique across the WHOLE docker
                                      # host, even across different compose
                                      # projects/directories — Docker container
                                      # names are global, not per-project.

    hostname: papaframe               # This becomes the tailnet device name,
                                      # and therefore the subdomain:
                                      # papaframe.your-tailnet.ts.net

    environment:
      - TS_AUTHKEY=tskey-auth-REPLACE_ME
        # The auth key from Step 1. Used once to register this device with
        # your tailnet. After first successful auth, the key itself is no
        # longer needed (state persists in TS_STATE_DIR), but leaving it set
        # is harmless — it's ignored once already authenticated.

      - TS_SERVE_CONFIG=/config/serve.json
        # Points tailscaled at the serve.json we wrote in Step 2 (mounted
        # into the container at /config below). tailscaled reads this on
        # startup and applies it automatically — equivalent to manually
        # running `tailscale serve`/`tailscale funnel` commands, but
        # declarative and reapplied every time the container restarts.

      - TS_STATE_DIR=/var/lib/tailscale
        # Where tailscaled stores this device's identity/keys/state. This
        # MUST be a persistent volume (see below) — without it, the container
        # would register as a brand-new device every time it restarts,
        # cluttering your tailnet with duplicate/orphaned nodes.

      # - TS_EXTRA_ARGS=--advertise-tags=tag:container
        # Applies the tag you associated with your auth key (or want this
        # device to carry) so ACL rules — like the Funnel grant — apply to it
        # automatically. Optional if you're not using tag-based ACLs.

    volumes:
      - ./ts-state:/var/lib/tailscale
        # Persists tailscaled's state directory (see TS_STATE_DIR above)
        # across container restarts/recreation.

      - ./ts-config:/config
        # Makes serve.json (Step 2) available inside the container at the
        # path referenced by TS_SERVE_CONFIG.

    devices:
      - /dev/net/tun:/dev/net/tun
        # Grants access to the kernel's TUN device, required for Tailscale to
        # create its virtual network interface. Without this, tailscaled
        # cannot establish tunnels at all.

    cap_add:
      - net_admin     # Needed to create/configure network interfaces.
      - sys_module    # Needed in some environments for Tailscale's
                       # networking setup (e.g. loading the wireguard module
                       # if not already present on the host kernel).

    restart: always
    # restart: unless-stopped
      # Keeps the sidecar (and therefore the whole instance, since
      # immich-frame depends on its network) running across LXC/Docker
      # restarts.

  # --- The actual immich-frame application container. ---
  immich-frame1:
    image: ghcr.io/immichframe/immichframe:latest
    # image: ghcr.io/immichframe/immichframe:v1.0.34.0
    # Replace with whichever immich-frame image/tag you're using.
    container_name: immich-frame1
    network_mode: service:ts-frame1
      # This is the key line: instead of getting its own network stack,
      # this container shares the ts-frame1 sidecar's network namespace
      # entirely. That's why serve.json above can proxy to 127.0.0.1:8080 —
      # from the sidecar's perspective, immich-frame IS localhost.
      # Because of this, immich-frame1 must NOT define its own `ports:`,
      # `hostname:`, or network-related settings — those all belong to the
      # sidecar it's attached to.

    depends_on:
      - ts-frame1
      # Ensures the sidecar's network namespace exists before immich-frame
      # starts trying to use it.

    env_file:
      - .env

    environment:
      TZ: "Etc/MST"

    user: "1000:1000"

    # volumes:
      # - ./frame1-config:/app/Config
        # immich-frame's own persistent config/data volume — adjust the
        # container-side path if your image uses a different config
        # location.

    restart: always
    # restart: unless-stopped
nanaframe — docker-compose.yml
services:
  # --- The Tailscale sidecar: this is what gives the instance its own
  # tailnet identity, hostname, TLS cert, and Funnel exposure. ---
  ts-frame2:
    image: tailscale/tailscale:latest
    container_name: ts-frame2         # Must be unique across the WHOLE docker
                                      # host, even across different compose
                                      # projects/directories — Docker container
                                      # names are global, not per-project.

    hostname: nanaframe               # This becomes the tailnet device name,
                                      # and therefore the subdomain:
                                      # nanaframe.your-tailnet.ts.net

    environment:
      - TS_AUTHKEY=tskey-auth-REPLACE_ME
        # The auth key from Step 1. Used once to register this device with
        # your tailnet. After first successful auth, the key itself is no
        # longer needed (state persists in TS_STATE_DIR), but leaving it set
        # is harmless — it's ignored once already authenticated.

      - TS_SERVE_CONFIG=/config/serve.json
        # Points tailscaled at the serve.json we wrote in Step 2 (mounted
        # into the container at /config below). tailscaled reads this on
        # startup and applies it automatically — equivalent to manually
        # running `tailscale serve`/`tailscale funnel` commands, but
        # declarative and reapplied every time the container restarts.

      - TS_STATE_DIR=/var/lib/tailscale
        # Where tailscaled stores this device's identity/keys/state. This
        # MUST be a persistent volume (see below) — without it, the container
        # would register as a brand-new device every time it restarts,
        # cluttering your tailnet with duplicate/orphaned nodes.

      # - TS_EXTRA_ARGS=--advertise-tags=tag:container
        # Applies the tag you associated with your auth key (or want this
        # device to carry) so ACL rules — like the Funnel grant — apply to it
        # automatically. Optional if you're not using tag-based ACLs.

    volumes:
      - ./ts-state:/var/lib/tailscale
        # Persists tailscaled's state directory (see TS_STATE_DIR above)
        # across container restarts/recreation.

      - ./ts-config:/config
        # Makes serve.json (Step 2) available inside the container at the
        # path referenced by TS_SERVE_CONFIG.

    devices:
      - /dev/net/tun:/dev/net/tun
        # Grants access to the kernel's TUN device, required for Tailscale to
        # create its virtual network interface. Without this, tailscaled
        # cannot establish tunnels at all.

    cap_add:
      - net_admin     # Needed to create/configure network interfaces.
      - sys_module    # Needed in some environments for Tailscale's
                       # networking setup (e.g. loading the wireguard module
                       # if not already present on the host kernel).

    restart: always
    # restart: unless-stopped
      # Keeps the sidecar (and therefore the whole instance, since
      # immich-frame depends on its network) running across LXC/Docker
      # restarts.

  # --- The actual immich-frame application container. ---
  immich-frame2:
    image: ghcr.io/immichframe/immichframe:latest
    # image: ghcr.io/immichframe/immichframe:v1.0.31.0
    # Replace with whichever immich-frame image/tag you're using.
    container_name: immich-frame2
    network_mode: service:ts-frame2
      # This is the key line: instead of getting its own network stack,
      # this container shares the ts-frame2 sidecar's network namespace
      # entirely. That's why serve.json above can proxy to 127.0.0.1:8080 —
      # from the sidecar's perspective, immich-frame IS localhost.
      # Because of this, immich-frame2 must NOT define its own `ports:`,
      # `hostname:`, or network-related settings — those all belong to the
      # sidecar it's attached to.

    depends_on:
      - ts-frame2
      # Ensures the sidecar's network namespace exists before immich-frame
      # starts trying to use it.

    env_file:
      - .env

    environment:
      TZ: "Etc/MST"

    user: "1000:1000"

    # volumes:
      # - ./frame1-config:/app/Config
        # immich-frame's own persistent config/data volume — adjust the
        # container-side path if your image uses a different config
        # location.

    restart: always
    # restart: unless-stopped

Key points:

  • container_name must be globally unique on the Docker host across all compose projects — Docker container names are not scoped per project.
  • hostname on the sidecar becomes the Tailscale device name and .ts.net subdomain.
  • restart: always (not unless-stopped) is used on this host so containers come back up automatically after LXC restarts.
  • user: "1000:1000" is set on the ImmichFrame container.
  • The frame-config volume for ImmichFrame app config is currently commented out — config is handled entirely via .env.

Step 4 — Write .env

Each instance has its own .env with its own Immich API key and album UUID.

papaframe — .env.example
ImmichServerUrl="http://192.168.0.71:2283"
# Either ApiKey or ApiKeyFile must be specified.
ApiKey="REPLACE_WITH_IMMICH_API_KEY"
# ApiKeyFile=/path/to/key

AuthenticationSecret=REPLACE_WITH_AUTH_SECRET
Interval=15
# TransitionDuration=2
ImageZoom=true
ImagePan=true
Layout=splitview
# DownloadImages=false
# ShowMemories=false
# ShowFavorites=false
# ShowArchived=false
# ImagesFromDays=
# ImagesFromDate=
# ImagesUntilDate=
# RenewImagesDuration=1
# Rating=5
Albums=REPLACE_WITH_ALBUM_UUID
# ExcludedAlbums=ALBUM3,ALBUM4
# People=PERSON1,PERSON2
# Webcalendars=https://calendar.mycalendar.com/basic.ics,webcal://calendar.mycalendar.com/basic.ics
# RefreshAlbumPeopleInterval=12
# ShowClock=true
# ClockFormat=hh:mm
# ClockDateFormat=eee, MMM d
# ShowProgressBar=true
# ShowPhotoDate=true
# PhotoDateFormat=yyyy-MM-dd
# ShowImageDesc=true
ShowPeopleDesc=false
ShowAlbumName=false
ShowImageLocation=true
# ImageLocationFormat=City,State,Country
# PrimaryColor=#F5DEB3
# SecondaryColor=#000000
# Style=none
# BaseFontSize=17px
WeatherApiKey=REPLACE_WITH_OPENWEATHERMAP_API_KEY
ShowWeatherDescription=true
# WeatherIconUrl=https://openweathermap.org/img/wn/{IconId}.png
UnitSystem=imperial
WeatherLatLong="32.2217,-110.9265"
# Language=en
# Webhook=
nanaframe — .env.example
ImmichServerUrl="http://192.168.0.71:2283"
# Either ApiKey or ApiKeyFile must be specified.
ApiKey="REPLACE_WITH_IMMICH_API_KEY"
# ApiKeyFile=/path/to/key

AuthenticationSecret=REPLACE_WITH_AUTH_SECRET
Interval=15
# TransitionDuration=2
ImageZoom=true
ImagePan=true
Layout=splitview
# DownloadImages=false
# ShowMemories=false
# ShowFavorites=false
# ShowArchived=false
# ImagesFromDays=
# ImagesFromDate=
# ImagesUntilDate=
# RenewImagesDuration=1
# Rating=5
Albums=REPLACE_WITH_ALBUM_UUID
# ExcludedAlbums=ALBUM3,ALBUM4
# People=PERSON1,PERSON2
# Webcalendars=https://calendar.mycalendar.com/basic.ics,webcal://calendar.mycalendar.com/basic.ics
# RefreshAlbumPeopleInterval=12
# ShowClock=true
# ClockFormat=hh:mm
# ClockDateFormat=eee, MMM d
# ShowProgressBar=true
# ShowPhotoDate=true
# PhotoDateFormat=yyyy-MM-dd
# ShowImageDesc=true
ShowPeopleDesc=false
ShowAlbumName=false
ShowImageLocation=true
# ImageLocationFormat=City,State,Country
# PrimaryColor=#F5DEB3
# SecondaryColor=#000000
# Style=none
# BaseFontSize=17px
WeatherApiKey=REPLACE_WITH_OPENWEATHERMAP_API_KEY
ShowWeatherDescription=true
# WeatherIconUrl=https://openweathermap.org/img/wn/{IconId}.png
UnitSystem=imperial
WeatherLatLong="32.2217,-110.9265"
# Language=en
# Webhook=

Key variables:

Variable Description
ImmichServerUrl URL of your Immich instance
ApiKey Immich API key for this frame's account
AuthenticationSecret Secret for ImmichFrame's own auth
Albums Album UUID to display (from Immich)
WeatherApiKey OpenWeatherMap API key
WeatherLatLong Coordinates for weather display
UnitSystem imperial or metric
Interval Seconds between photo transitions
Layout e.g. splitview

Step 5 — ACL check (one-time, tailnet-wide)

If using tag-based ACLs, ensure the tag (e.g. tag:container) is granted the funnel node attribute. On default/no custom ACLs this can usually be skipped.

Step 6 — Bring it up

cd /home/jake/papaframe_v3   # or nanaframe_v3
sudo docker compose up -d

Then:

  1. Check the Tailscale admin console — the new device should appear within seconds.
  2. Wait for the TLS certificate to be provisioned.
  3. Visit https://papaframe.your-tailnet.ts.net to confirm ImmichFrame is serving.

Adding another instance

Naming — what controls what

There are three naming concepts that are easy to conflate:

Setting Where What it controls
container_name docker-compose.yml, both services The NAME column in docker ps — this is how you identify running containers on the host
hostname docker-compose.yml, sidecar service only The Tailscale device name and .ts.net subdomain (e.g. jakeframe.tail01024c.ts.net)
Service name (e.g. ts-jakeframe:) docker-compose.yml top-level key Only used internally within the compose file — specifically in network_mode: service:<name> and depends_on. Not visible in docker ps.

Keep container_name and the service name in sync (e.g. both ts-jakeframe) — they don't have to match but it's much less confusing when they do.

What to change per new instance

Copy an existing instance directory and update:

  • Directory name — e.g. jakeframe/
  • Service names in docker-compose.yml — e.g. ts-jakeframe: and immich-jakeframe: (these are the top-level keys under services:)
  • container_name on both services — e.g. ts-jakeframe and immich-jakeframe (must be globally unique on the Docker host)
  • hostname on the sidecar — e.g. jakeframe (sets the Tailscale device name and .ts.net subdomain)
  • network_mode on the ImmichFrame service — must reference the new sidecar service name, e.g. network_mode: service:ts-jakeframe
  • depends_on on the ImmichFrame service — must reference the new sidecar service name
  • TS_AUTHKEY — new key, or reuse an existing reusable one
  • ApiKey and Albums in .env — specific to this frame's Immich account/album

The internal port (8080 in serve.json) does not need to change — each stack's network namespace is isolated, so there's no port collision.

jakeframe — docker-compose.yml (worked example)
services:
  # Service names (ts-jakeframe, immich-jakeframe) are only used inside this
  # compose file — they are NOT what appears in `docker ps`. What appears in
  # `docker ps` is container_name. Keep service names and container_name in
  # sync to avoid confusion.
  ts-jakeframe:
    image: tailscale/tailscale:latest
    container_name: ts-jakeframe       # ← This is what shows in `docker ps` NAME column.
                                        #   Must be unique across ALL containers on the
                                        #   Docker host, not just this compose project.

    hostname: jakeframe                # ← This becomes the Tailscale device name and
                                        #   the .ts.net subdomain:
                                        #   jakeframe.tail01024c.ts.net

    environment:
      - TS_AUTHKEY=tskey-auth-REPLACE_ME
      - TS_SERVE_CONFIG=/config/serve.json
      - TS_STATE_DIR=/var/lib/tailscale
      # - TS_EXTRA_ARGS=--advertise-tags=tag:container

    volumes:
      - ./ts-state:/var/lib/tailscale
      - ./ts-config:/config

    devices:
      - /dev/net/tun:/dev/net/tun

    cap_add:
      - net_admin
      - sys_module

    restart: always

  immich-jakeframe:
    image: ghcr.io/immichframe/immichframe:latest
    container_name: immich-jakeframe   # ← This is what shows in `docker ps` NAME column.

    network_mode: service:ts-jakeframe  # ← Must reference the service name above
                                         #   (ts-jakeframe), not the container_name.
                                         #   This is the only place service name matters.

    depends_on:
      - ts-jakeframe

    env_file:
      - .env

    environment:
      TZ: "Etc/MST"

    user: "1000:1000"

    restart: always
jakeframe — .env.example (worked example)
ImmichServerUrl="http://192.168.0.71:2283"
# Either ApiKey or ApiKeyFile must be specified.
ApiKey="REPLACE_WITH_IMMICH_API_KEY"
# ApiKeyFile=/path/to/key

AuthenticationSecret=REPLACE_WITH_AUTH_SECRET
Interval=15
# TransitionDuration=2
ImageZoom=true
ImagePan=true
Layout=splitview
Albums=REPLACE_WITH_ALBUM_UUID
ShowPeopleDesc=false
ShowAlbumName=false
ShowImageLocation=true
WeatherApiKey=REPLACE_WITH_OPENWEATHERMAP_API_KEY
ShowWeatherDescription=true
UnitSystem=imperial
WeatherLatLong="32.2217,-110.9265"

Updating

cd /home/jake/papaframe_v3
sudo docker compose pull
sudo docker compose up -d

Repeat for nanaframe_v3/. The ghcr.io/immichframe/immichframe:latest tag is used; pinned version tags are left as comments in the compose files for rollback reference.


mkdocs sidecar (docs site)

Same Tailscale-sidecar pattern as the frame instances, reused for a completely different service: a local mkdocs homeserver documentation site, git-synced to GitHub, served over its own .ts.net hostname (mkdocs.tail01024c.ts.net) via Funnel. Lives alongside papaframe_v3/ and nanaframe_v3/ on this same LXC.

Why here and not the Tailscale subnet router

This container's only job is to route the LAN — no additional services should run on it. Frames-LXC already hosts unrelated Docker sidecar services, so it's the right home for one more.

Unlike the ImmichFrame instances, there's no pre-built image that matches this site's exact plugin set, so the mkdocs container is built from a small Dockerfile against the cloned repo rather than pulled from a registry.

Directory layout

mkdocs-site/
├── docker-compose.yml
├── ts-config/
│   └── serve.json
└── ts-state/               ← created automatically

homeserver-docs/            ← the git-cloned docs repo, kept separate — see note below
├── mkdocs.yml
├── docs/
├── requirements.txt        ← from `pip freeze` in the local .venv
└── Dockerfile

Keep the docs repo and the compose files separate

docker-compose.yml, ts-config/, and ts-state/ are deployment config, not documentation content — they don't belong inside the git repo that syncs to GitHub. ts-state/ holds Tailscale node keys, and there's no reason to risk them ending up in git history. Only homeserver-docs/ is the repo; mkdocs-site/ stays local to the LXC.

Step 1 — Install git and clone the docs repo

sudo apt update
sudo apt install -y git

Clone the repo to the LXC (use SSH if the repo is private and you've already added a deploy key or your own key to GitHub, otherwise HTTPS with a personal access token works too):

cd /home/jake
git clone git@github.com:yourusername/homeserver-docs.git

If this is the first time this LXC has connected to GitHub over SSH, add a key:

ssh-keygen -t ed25519 -C "frames-lxc-mkdocs"
cat ~/.ssh/id_ed25519.pub

Paste the printed public key into GitHub → Settings → SSH and GPG keys (or, for a repo-scoped key with narrower access, Repo → Settings → Deploy keys).

Step 2 — Export dependencies from the local .venv

On the machine where the .venv currently lives (not the LXC):

source .venv/bin/activate
pip freeze > requirements.txt

Commit requirements.txt to the repo and push, then pull it down on the LXC along with the rest of the repo.

Step 3 — Write the Dockerfile

Goes in the root of homeserver-docs/, next to mkdocs.yml:

FROM python:3.12-slim
WORKDIR /docs
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000
CMD ["mkdocs", "serve", "-a", "0.0.0.0:8000"]

The -a 0.0.0.0:8000 bind is required — mkdocs serve's default of 127.0.0.1 would only be reachable from inside the mkdocs container itself, not from the sidecar proxying to it over the shared network namespace.

Step 4 — Write ts-config/serve.json

Same shape as the frame instances, pointed at mkdocs's port instead of 8080:

{
  "TCP": {
    "443": { "HTTPS": true }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": {
          "Proxy": "http://127.0.0.1:8000"
        }
      }
    }
  },
  "AllowFunnel": {
    "${TS_CERT_DOMAIN}:443": true
  }
}

If mkdocs is ever configured to serve on a different port, 8000 here is the only line that needs to change.

Step 5 — Write docker-compose.yml

services:
  ts-mkdocs:
    image: tailscale/tailscale:latest
    container_name: ts-mkdocs
    hostname: mkdocs
    environment:
      - TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_SERVE_CONFIG=/config/serve.json
    volumes:
      - ./ts-state:/var/lib/tailscale
      - ./ts-config:/config
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - NET_ADMIN
      - NET_RAW
    restart: always

  mkdocs:
    build: /home/jake/homeserver-docs
    container_name: mkdocs
    network_mode: service:ts-mkdocs
    depends_on:
      - ts-mkdocs
    volumes:
      - /home/jake/homeserver-docs:/docs
    restart: always

As with the frame instances, container_name (ts-mkdocs, mkdocs) must be globally unique on the Docker host — check it against ts-frame1/immich-frame1 and ts-frame2/immich-frame2 before bringing it up. hostname: mkdocs on the sidecar is what sets the .ts.net subdomain, independent of the container names.

The homeserver-docs/ bind mount means a plain git pull inside the repo updates the live site immediately — no rebuild needed for content changes. A rebuild (docker compose up -d --build) is only required when requirements.txt changes.

Step 6 — Generate a Tailscale auth key and bring it up

Same as the frame instances — reusable, not ephemeral, tagged if you use ACLs. Paste it into TS_AUTHKEY, then:

mkdir -p /home/jake/mkdocs-site/ts-config
cd /home/jake/mkdocs-site
sudo docker compose up -d --build

Check the Tailscale admin console for the new mkdocs device, wait for cert provisioning, then visit https://mkdocs.tail01024c.ts.net.

Updating content

cd /home/jake/homeserver-docs
git pull

No container restart needed — mkdocs serve picks up file changes on its own via the bind mount. Only rebuild the image (docker compose up -d --build from mkdocs-site/) after a requirements.txt change.