I’m excited to share a little troubleshooting journey I recently went through while deploying a Python web application inside Docker. If you’ve ever felt confident that “Docker makes everything just work” only to be tripped up by a bizarre bind error you’re in good company.
- The minimal Dockerfile and
main.py
that reproduce the problem. - A deep dive into the error itself and why it happens.
- The simple fix that gets your containerized Sanic server back online.
- A few “practice” enhancements health checks, environment-driven configuration, and basic logging to turn this demo into a sturdier template you can reuse.
Dockerfile & Application Code
I started with the most stripped-down setup I could imagine. <details> <summary><strong>Dockerfile</strong></summary>
python:3.8
WORKDIR /usr/src/app
# Install dependencies
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
EXPOSE 8000
# By default, this invokes Sanic's CLI,
# which binds to ::1 (IPv6 localhost) unless overridden.
CMD ["python3", "apps/main.py", "start", "--config", "config.yml"]
</details> <details> <summary><strong>apps/main.py</strong></summary>
# apps/main.py
from sanic import Sanic
from sanic.response import json
app = Sanic("DemoApp")
@app.route("/")
async def home(request):
return json({"message": "Hello from inside Docker!"})
if __name__ == "__main__":
# Sanic.create().run() without args binds to IPv6 localhost (::1) by default.
Sanic.create(app).run(
host="::1",
port=8000,
debug=True,
autoreload=True,
)
Error Code
When I built and ran this container, I was expecting to see my “Hello from inside Docker!” message. Instead, it crashed immediately with:
: [Errno 99] error while attempting to bind on address ('::1', 8000, 0, 0):
cannot assign requested address
[INFO] Server Stopped
Under the Hood: What’s Really Going On
At first glance, “Errno 99” might feel impenetrable, but here’s the core of the issue:
- Sanic’s default host (when you don’t explicitly pass
--host
on the CLI) is::1
the IPv6 loopback address. - Docker containers, out of the box, don’t always configure an IPv6 loopback interface. Only the IPv4 loopback (
127.0.0.1
) is guaranteed. - As a result, when Sanic calls
bind("::1", 8000)
, the kernel says, “I have no idea what::1
is here,” and refuses to create the socket.
Because the server never binds successfully, the container’s main process exits and Docker stops the container.
The Fix: Bind to 0.0.0.0
The solution is straightforward: tell Sanic to listen on IPv4’s “all interfaces” address 0.0.0.0
. This ensures it will bind to the container’s network interface that’s mapped to the host.
Update main.py
Directly
- Sanic.create(app).run(
- host="::1",
+ Sanic.create(app).run(
+ host="0.0.0.0", # bind to all IPv4 interfaces
port=8000,
debug=True,
autoreload=True,
)
Override via the Docker CMD
Alternatively, you can leave your code untouched and pass the bind flags in the Dockerfile:
-CMD ["python3", "apps/main.py", "start", "--config", "config.yml"]
+CMD [
+ "python3", "apps/main.py", "start",
+ "--host", "0.0.0.0",
+ "--port", "8000",
+ "--config", "config.yml"
+]
Then rebuild and run:
build -t demo-app .
docker run -p 8000:8000 demo-app
You should now see your Sanic server come up cleanly, and you’ll be able to curl http://localhost:8000/
to verify the JSON response.
Practice Enhancements
Once the basic binding issue is solved, I like to add a few small improvements to turn this into a more reusable boilerplate.
Environment Driven Configuration
Hard coding host, port, and debug flags can be inflexible. Instead, read them from environment variables:
# apps/main.py
import os
from sanic import Sanic
from sanic.response import json
app = Sanic("DemoApp")
@app.route("/")
async def home(request):
return json({"message": "Hello from inside Docker!"})
@app.route("/health")
async def health(request):
return json({"status": "OK"})
if __name__ == "__main__":
host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", 8000))
debug = os.getenv("SANIC_DEBUG", "false").lower() == "true"
Sanic.create(app).run(
host=host,
port=port,
debug=debug,
autoreload=debug,
)
And in the Dockerfile:
APP_HOST=0.0.0.0
ENV APP_PORT=8000
ENV SANIC_DEBUG=true
CMD ["python3", "apps/main.py", "start"]
Now you can tweak behavior at deploy time without rebuilding your image.
Health-Check Endpoint
I added a GET /health
route so that Docker or Kubernetes can periodically poll it:
@app.route("/health")
async def health(request):
return json({"status": "OK"})
This helps you wire in readiness and liveness probes in orchestrators.
Basic Logging
Sanic includes a built-in logger. A quick setup lets you log inside your routes:
logging
log = logging.getLogger("sanic.root")
log.setLevel(logging.INFO)
@app.route("/items/<item_id>")
async def get_item(request, item_id):
log.info(f"Fetching item {item_id}")
# … pretend we fetch from a database …
return json({"item_id": item_id})
With logs going to stdout
, you can aggregate and inspect them in production.
Final Thought
I’m continually reminded that containerizing an application can surface unexpected assumptions like default IPv6 binding that never come up in local development. By explicitly binding to 0.0.0.0
, introducing environment-driven configuration, health-check endpoints, and structured logging, I’ve turned a minimal repro into a flexible starting point you can drop into almost any Python-in-Docker project.