Using Docker Compose and Caddy for Local Network Services on Raspberry Pi

This blog post explains how Docker Compose and Caddy can make web services accessible on your local network when deployed to a Raspberry Pi. We'll explore different approaches to URL management to access services.

Before following this tutorial, make sure you have read the blog post Deploying Applications to Raspberry Pi with Docker Compose (from my Mac). This post explains how to set up a Docker remote context to quickly deploy applications to a remote machine like a Raspberry Pi.

Prerequisites

Before starting, you need to set up a remote Docker context to manage your Raspberry Pi:

docker context create \
    --docker host=ssh://k33g@t1000.local \
    --description="Remote engine on Raspberry Pi" \
    t1000-remote

# Switch to the remote context
docker context use t1000-remote

Replace t1000.local with your Pi's hostname or IP address (e.g., 192.168.8.175), and k33g with your Pi username.

Project Structure

Our project consists of the following files:

.
├── compose.yaml
├── Caddyfile
├── caddy.Dockerfile
├── web1/
│   ├── main.go
│   ├── go.mod
│   ├── go.sum
│   └── Dockerfile
└── web2/
    ├── main.go
    ├── go.mod
    ├── go.sum
    └── Dockerfile

Web1 Service

package main

import (
    "log"
    "net/http"
    "os"
)

func main() {
    var httpPort = os.Getenv("HTTP_PORT")

    mux := http.NewServeMux()

    mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) {
        response.Header().Add("Content-Type", "text/html;charset=utf-8")
        response.Write([]byte("<h1>WEB 1️⃣ - 👋 Hello World 🌍</h1>"))
    })

    var errListening error
    log.Println("🌍 http server is listening on: " + httpPort)
    errListening = http.ListenAndServe(":"+httpPort, mux)

    log.Fatal(errListening)
}

go.mod:

module tiny-service

go 1.22.1

To generate the go.sum file, run:

go mod tidy

You can do this within the Dockerfile as well

Web2 Service

package main

import (
    "log"
    "net/http"
    "os"
)

func main() {
    var httpPort = os.Getenv("HTTP_PORT")

    mux := http.NewServeMux()

    mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) {
        response.Header().Add("Content-Type", "text/html;charset=utf-8")
        response.Write([]byte("<h1>WEB 2️⃣ - 👋 Hello World 🌍</h1>"))
    })

    var errListening error
    log.Println("🌍 http server is listening on: " + httpPort)
    errListening = http.ListenAndServe(":"+httpPort, mux)

    log.Fatal(errListening)
}

go.mod:

module tiny-service

go 1.22.1

Dockerfile for the Web Services

I use a multi-stage Dockerfile to build the Go applications and create a minimal image for deployment. It's the same for both services.

FROM golang:1.22.1-alpine AS buildernext
WORKDIR /app
COPY main.go .
COPY go.mod .
COPY go.sum .
RUN go build

FROM scratch
WORKDIR /app
COPY --from=buildernext /app/tiny-service .
CMD ["./tiny-service"]

Caddy Reverse Proxy

caddy.Dockerfile

FROM caddy:2-alpine

COPY Caddyfile /etc/caddy/Caddyfile

Caddyfile

The configuration will depend on the URL management approach you choose.

Docker Compose Configuration

The compose.yaml file defines three services:

  • Two web services (web1 and web2)

  • A Caddy reverse proxy

services:
  web1:
    build:
      context: ./web1
      dockerfile: Dockerfile
    environment:
      - HTTP_PORT=80
    networks:
      - proxy-network

  web2:
    build:
      context: ./web2
      dockerfile: Dockerfile
    environment:
      - HTTP_PORT=80
    networks:
      - proxy-network

  caddy:
    build:
      context: .
      dockerfile: caddy.Dockerfile
    ports:
      - "80:80"
    networks:
      - proxy-network

networks:
  proxy-network:

URL Management Approaches

Here are three main approaches to manage service URLs:

  1. Route-Based Access

  2. Subdomain-Based

  3. nip.io based Access

Deployment

  1. Save your chosen Caddyfile configuration

  2. Deploy the services using Docker Compose:

docker compose up -d

use --build flag to rebuild images

This will build and start all services with the appropriate networking configuration and make them accessible according to your chosen URL management approach.

1. Route-Based Access

You will access the services via:

Caddyfile configuration:

:80 {
    handle /web1* {
        uri strip_prefix /web1
        reverse_proxy web1:80
    }

    handle /web2* {
        uri strip_prefix /web2
        reverse_proxy web2:80
    }

    handle / {
        respond "Welcome to the API gateway"
    }
}

Pros:

  • Simple setup

  • No DNS configuration is needed

  • Works with any hostname or IP

Cons:

  • Less clean URLs

  • All services share the same domain

2. Subdomain-Based Access

You will access the services via:

Caddyfile configuration:

{
    auto_https off
    admin off
}

http://web1.t1000.local {
    reverse_proxy web1:80
}

http://web2.t1000.local {
    reverse_proxy web2:80
}

Requires adding to /etc/hosts:

192.168.8.175 web1.t1000.local web2.t1000.local

Pros:

  • Cleaner URLs

  • Service isolation

Cons:

  • Requires host file modification

  • Configuration needed on each client device

3. nip.io based Access

What is nip.io?

nip.io is a clever DNS service that automatically resolves domain names to IP addresses based on the domain itself. This allows you to create custom subdomains that point to specific IP addresses without the need for a DNS server or host file modifications.

When using web1.192.168.8.175.nip.io:

  1. Your browser requests the IP for web1.192.168.8.175.nip.io

  2. nip.io extracts the IP (192.168.8.175) from the domain name

  3. Returns that IP automatically

web1.192.168.8.175.nip.io
|    |             |
|    |             └── nip.io domain service
|    └── IP address you want to resolve to
└── subdomain (can be anything)

Similar services: xip.io, sslip.io

So, now, you will access the services via:

Caddyfile configuration:

{
    auto_https off
    admin off
}

http://web1.192.168.8.175.nip.io {
    log {
        format console
    }
    reverse_proxy /* web1:80
}

http://web2.192.168.8.175.nip.io {
    log {
        format console
    }
    reverse_proxy /* web2:80
}

Pros:

  • No host file modifications

  • Works from any device on the network

  • No DNS server needed

  • Perfect for local development

Cons:

  • Requires internet access for DNS resolution

  • Depends on external service (nip.io)

Conclusion

For local development and testing, the nip.io approach is my favourite solution. It requires minimal setup and works without configuration across all devices on your network.

1
Subscribe to my newsletter

Read articles from Philippe Charrière directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Philippe Charrière
Philippe Charrière