> ## Content Index
> Fetch the complete content index at: https://blog.davidjardine.ca/llms.txt
> Use this file to discover other available public pages before exploring further.

# Self-Hosting Docker Containers with Caddy and Tailscale
- URL: https://blog.davidjardine.ca/self-host-docker-containers-caddy-tailscale/
- Published: 2026-11-05T08:16:16.000Z
- Updated: 2026-11-05T08:24:02.000Z
- Description: My quick and non-professional guide to how I host my docker containers using caddy and tailscale
- Author: David Jardine
- Tags: #Import 2026-07-29 07:22

I wrote this post several months ago, and the day after I finished my final draft Tailscale announced their new "Services" feature and I was sure everything I did here was about to become obsolete. Jump to present day and I still am using this method for a few reasons. 

First, the classic resistance to change - it always feels easier to stick with what you know. In this case though, I do feel it is somewhat justified by the second reason, which is it is still a beta feature. And building off that, there are several things I've tried to set up using the Services feature in Tailscale that just don't work. 

The fact that I haven't moved over to running all my homelab via Tailscale Services might be an indictment of my network engineering skills, but ultimately I'm always going to choose the option that I understand better. Since this feature is still in beta and I've used the former for longer, I'm sticking with it. 

It has kept my homelab services running reliably for over a year and I thought I should just go ahead and put this post out there since I've always found it difficult to find good guides on self-hosting. So here's hoping this is helpful to someone!

If you don't want any of the explanation and just want the files, I have a github repository setup with the example files I explain below:

[GitHub - Sample Docker Compose with Caddy and Tailscale](https://github.com/davidjardine/sample-docker-compose-caddy-tailscale?ref=blog.davidjardine.ca)

---

I'll quickly cover what the context / setup of my homelab is so you can see if this applies to your situation as well. Then I'll explain why I chose to set up my services in this manner, and finally I'll give a basic overview of how it works. 

⚠️

Remember that I only do this as a hobby. I'm fairly confident I'm not recommending anything critically unsafe here but I can't guarantee my methods follow self-hosting best practices either.

## What is my Homelab?

In my homelab, I am running my services off a handful of mini PCs (mostly Lenovo ThinkCentres like the m70q) and a collection of various old PCs that I've collected from friends and family members that were otherwise going to be thrown away. 

I do have one server back at my mom's house that serves as a backup in case the internet goes down so it can run some of the basic stuff that she uses, but otherwise these are all located in a single location at my home.

In terms of users, I am by far the most frequent user of the homelab, relying on it for several hours every day for entertainment and productivity purposes, but I'm not the only regular user. Three other family members are regular users (at least several times a week) and two of them have reached the point that even a few minutes of downtime can sometimes result in panicked texts asking what happened to the servers. Additionally, there are a handful of others who have access but rarely login to the services. That is all to say, I do require pretty consistent reliability when it comes to running my services.

## Why This Configuration?

In order to facilitate off-site access for myself and the other users, I decided to use Tailscale. I am always experimenting with other methods (I dream of figuring out how to reliably use vanilla Wireguard to be fully in control of all my networks) but Tailscale is too easy for everyone to use and understand that I am nowhere near being able to move away from it. 

I chose Docker because of the ease in which I can choose to move my services between different machines if any of them either start to decline (since some of them are quite old and I expect them to start showing it any day) or if I start running new services and I want to optimize the use of my compute and storage capacity. And I use docker compose because I prefer to use the compose file to configure the app instead of doing it in docker desktop or using the command line.

I chose caddy because I have spent years as a web developer and I absolutely hate nginx and apache configs, and when I learned about caddy it was amazing. The configs are so simple to write and it got picked up fast so a lot of projects have a ready to use Caddyfile template that you can copy/paste for their self-hosted version.

ℹ️

I have lately been paying attention to discussion that caddy is perhaps too permissive with its AI code contributions, and I watch projects like [Anville](https://codeberg.org/sugar700/anville?ref=blog.davidjardine.ca) in case any of them reach an equivalent level of quality and reliability, but for now caddy still does work great.

## How It Works

Keep in mind this is not meant to be a professional explanation. I'm keeping it simple with the thought that anyone reading this is probably not someone with a deep technical background. It's also been years since I was in my comp sci courses so while I still get the concepts there are lots of specific terms I've forgotten.

For any docker compose project, you will have a compose.yaml file in your project's main directory. There will be at MINIMUM two containers established using the method I use to run a service that connects to your tailnet, but most will require three or more. I don't believe there's an official name but I've seen most people online refer to as the "tailscale sidecar" approach.

First, you have your tailscale container. This is the container that allows your app to be a part of your tailnet. Second you'll have a caddy container which handles the routing of the traffic. These are the only two containers you'd need if you wanted to run a basic web server that connects to your tailnet. If you want to run a full self-hosted service (i.e. Jellyfin/Plex, Immich, etc.) then you're looking at three or more depending what that service requires.

Below are two examples. The first is just to run the basic webserver, and the second is a simplified example of what a more complicated app's configuration might require.

## Example 1: Basic Web Server

This is a quick overview of what the directory would look like:

  /app-name
  └ .env
  └ compose.yaml
  └ Caddyfile
  └ site
    └ index.html
    └ index.css
  
### Docker Compose File

name: {$APP_NAME}
services:
  tailscale:
    container_name: {$APP_NAME}-ts
    image: tailscale/tailscale:latest
    restart: unless-stopped
    environment:
      - TS_AUTHKEY=tailscale-auth-key
      - TS_STATE_DIR=/var/lib/tailscale
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin
      - sys_module
    volumes:
      - ts-data:/var/lib/tailscale
      - sock-vol:/tmp
  caddy:
    container_name: {$APP_NAME}-caddy
    image: caddy/caddy:latest
    network_mode: service:tailscale
    cap_add:
      - net_admin
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./site:{$CADDY_SITE_DIR}
      - caddy-data:/data
      - caddy-config:/config
      - sock-vol:/var/run/tailscale
volumes:
  sock-vol:
  ts-data:
  caddy-data:
  caddy-config:

If you don't know how to use docker compose at all, I'm not going to get into every detail (check out the docs for how to write a compose file: [https://docs.docker.com/reference/compose-file](https://docs.docker.com/reference/compose-file?ref=blog.davidjardine.ca)) but this is a brief explanation of some of the key lines.

**container\_name**: I always include this in my compose files so that I know exactly what my containers are called, instead of relying on the auto-naming. The {$APP-NAME} would pull from a .env file in the same directory as the compose.yaml file, or you can replace it with the name of your app and hard-code it into the compose file.

**TS\_AUTHKEY**: This line under the environment variables is what you fill in by generating the key in your tailscale settings. Login to your account and go to settings, then choose keys and generate an Auth Key (not an OAuth key or API access token).

**network\_mode: service:tailscale**: This line under the caddy service makes it so that the caddy container connects to your tailnet via the tailscale container instead of trying to just use the standard ports for a webserver like 80 and 443.

**./site:{$CADDY\_SITE\_DIR}**: This line maps the directory named "site" in the local directory on your host system to the directory where you want the webserver files to be stored in the caddy container. I have this set up to be an environment variable because your Caddyfile will also need to refer to this directory in the caddy container. 

**volumes**: The two volumes with caddy in the name are required for any caddy container to run properly in docker. The ts-data volume is required for the tailscale container to store data between restarts. The sock-vol volume is needed to share the tailscale socket between any of the containers that will be using the tailscale network, which is why both containers have access to that volume.

### Caddyfile

{$APP_NAME}.your-tailnet-ID-here.ts.net {
  root * {CADDY_SITE_DIR}
  file_server
}

If you use the exact same directory structure as me, the Caddyfile will be in the same directory as your compose file which will let it pull from the same .env file. I've also included the example .env below.

The only thing you need to change would be the "your-tailnet-ID-here" part, since there isn't anywhere else that would be needed it doesn't make sense to put it in your environment variable file.

If you don't know how caddy works the first line tells it what URL to expect incoming traffic to be using, and the lines within the curly braces are what it will do with that traffic. The "root" line tells it where the root of the webserver will be, and in this case keep in mind this is the location within your caddy container – not the directory where the files are stored in your host system.

### .env

  APP_NAME=myWebsite
  CADDY_SITE_DIR=/srv/myWebsite

This is pretty simple so there are only two variables, both are just to make sure you don't need to re-type things in multiple places.

## Example 2: Complex Web Service

Most times that you want to setup something in this manner, it won't be a simple webserver and there will be more services in the docker compose file.

Some apps/services are pretty straightforward like Jellyfin, which only requires one additional container that runs everything it need to function but others like Immich have several containers that run alongside the main one to support it. Since Immich is very popular and has a more complex file, I'm going to use that here as the example.

Keep in mind that this is built entirely from the starter compose.yaml provided by Immich, which can be found here: [https://docs.immich.app/install/docker-compose/](https://docs.immich.app/install/docker-compose/?ref=blog.davidjardine.ca). This file has several lines that will break immich if you change any of them, so keep that in mind and review their guide at the previous link to learn more about why (I removed lots of the comments that explain things just to keep it simple since the purpose of this post is not to explain how Immich works).

### Docker Compose File

name: {$APP_NAME}
services:
  tailscale:
    container_name: {$APP_NAME}-ts
    image: tailscale/tailscale:latest
    restart: unless-stopped
    environment:
      - TS_AUTHKEY=tailscale-auth-key
      - TS_STATE_DIR=/var/lib/tailscale
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin
      - sys_module
    volumes:
      - ts-data:/var/lib/tailscale
      - sock-vol:/tmp
  caddy:
    container_name: {$APP_NAME}-caddy
    image: caddy/caddy:latest
    network_mode: service:tailscale
    cap_add:
      - net_admin
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
      - sock-vol:/var/run/tailscale
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    volumes:
      - ${UPLOAD_LOCATION}:/data
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    network_mode: service:tailscale
    depends_on:
      - redis
      - database
    restart: always
    healthcheck:
      disable: false

  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
    volumes:
      - model-cache:/cache
    env_file:
      - .env
    restart: always
    healthcheck:
      disable: false

  redis:
    container_name: immich_redis
    image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
    healthcheck:
      test: redis-cli ping | grep -q PONG || exit 1
    restart: always

  database:
    container_name: immich_postgres
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_INITDB_ARGS: '--data-checksums'
      # Uncomment next line if your database is stored on HDDs
      # DB_STORAGE_TYPE: 'HDD'
    volumes:
      - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
    shm_size: 128mb
    restart: always
    healthcheck:
      disable: false

volumes:
  sock-vol:
  ts-data:
  caddy-data:
  caddy-config:
  model-cache:

All of the same things from the first example are in this compose file as well, they are just buried in more complex configurations since Immich's basic compose file is already more lengthy.

The only real difference is you don't need the line in the caddy service that links the local directory "site" to the directory in the caddy container where the webserver files would go because you are going to only run a reverse proxy for the immich server. 

### Caddyfile

  {$APP_NAME}.your-tailnet-ID-here.ts.net {
    reverse_proxy immich-server:2289
  }

### .env

Most of this is from the Immich recommended setup and you are supposed to make any changes in this file instead of the compose.yaml.

APP_NAME=immich

# The location where your uploaded files are stored
UPLOAD_LOCATION=./library

# The location where your database files are stored
DB_DATA_LOCATION=./postgres

# Optional, to set a timezone
# TZ=Etc/UTC

# The Immich version to use. ex: "v2" or "v2.1.0"
IMMICH_VERSION=v3

# Connection secret for postgres
# should be changed to a long random set of characters
# Please use only letters and numbers 
# no special characters or spaces
DB_PASSWORD=postgres

# The values below do not need to be changed
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

Again, this isn't meant to be a tutorial on setting up Immich – just showing how the tailscale + caddy containers get added to a docker compose setup for a more complex app. Refer to the Immich documentation for details on how it works specifically: [https://docs.immich.app/install/docker-compose/](https://docs.immich.app/install/docker-compose/?ref=blog.davidjardine.ca)

All these files are also in the GitHub repo: [https://github.com/davidjardine/sample-docker-compose-caddy-tailscale](https://github.com/davidjardine/sample-docker-compose-caddy-tailscale?ref=blog.davidjardine.ca)

## Wrap Up

I hope this helps someone, or validates someone who is looking to see if their configuration is one that should work. Again, I'm far from an expert but I do really enjoy experimenting and playing around with self-hosted apps, and I just haven't seen a ton of guides for the docker + tailscale + caddy approach that I prefer (I really would love to never touch nginx configs again). 

If you want to reach out to ask me something you can reach me via email david@jars.to, or feel free to comment on this post. I also have reddit but full honesty, despite using it regularly I tend to ignore notifications and so I have sometimes missed messages for weeks at a time. But I am on there and a few other social media sites that I have linked on my main website: [https://www.davidjardine.ca](https://www.davidjardine.ca/?ref=blog.davidjardine.ca)

Best of luck with your self-hosting!