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
andweb2
)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:
Route-Based Access
Subdomain-Based
nip.io based Access
Deployment
Save your chosen Caddyfile configuration
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
:
Your browser requests the IP for
web1.192.168.8.175.nip.io
nip.io extracts the IP (
192.168.8.175
) from the domain nameReturns 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)
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.
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