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:
-
Tailscale sidecar (
tailscale/tailscale:latest) — registers as its own Tailscale device, terminates TLS using the auto-provisioned.ts.netcert, and exposes the instance via Funnel (public internet). Configured declaratively viaserve.json. -
ImmichFrame (
ghcr.io/immichframe/immichframe:latest) — the actual photo frame app. Usesnetwork_mode: service:ts-frameNto share the sidecar's network namespace, which is why the sidecar can proxy to127.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 makeshttps://papaframe.your-tailnet.ts.network 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 tofalseor 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_namemust be globally unique on the Docker host across all compose projects — Docker container names are not scoped per project.hostnameon the sidecar becomes the Tailscale device name and.ts.netsubdomain.restart: always(notunless-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-configvolume 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:
- Check the Tailscale admin console — the new device should appear within seconds.
- Wait for the TLS certificate to be provisioned.
- Visit
https://papaframe.your-tailnet.ts.netto 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:andimmich-jakeframe:(these are the top-level keys underservices:) container_nameon both services — e.g.ts-jakeframeandimmich-jakeframe(must be globally unique on the Docker host)hostnameon the sidecar — e.g.jakeframe(sets the Tailscale device name and.ts.netsubdomain)network_modeon the ImmichFrame service — must reference the new sidecar service name, e.g.network_mode: service:ts-jakeframedepends_onon the ImmichFrame service — must reference the new sidecar service nameTS_AUTHKEY— new key, or reuse an existing reusable oneApiKeyandAlbumsin.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.