DEV Community

Cover image for Deploying Your Streaming Chatbot with Docker: From Localhost to a Live URL
Marce
Marce

Posted on

Deploying Your Streaming Chatbot with Docker: From Localhost to a Live URL

In the previous article, we built a Node.js chatbot with real-time streaming, running from the terminal on our own machine. That's a great starting point, but it only works on your machine. To actually ship something — a demo people can open, a service other apps can call — it needs to run somewhere else, packaged in a way that behaves the same regardless of the underlying machine. That's what Docker and a deployment platform give us.

In this article, we'll take that same chatbot, add a small web interface, package it as a Docker image, publish it, and deploy it live with a public URL.

What you'll learn

  • Turning a script that exits into a long-running web service
  • Writing a Dockerfile for a Node.js app
  • Publishing an image to Docker Hub
  • Deploying a Docker image to Railway, with a public URL

Requirements

  • The chatbot project from the previous article
  • Docker Desktop installed and running
  • A free Docker Hub account
  • A free Railway account

A note on cost: Railway no longer has a permanent free tier — new accounts get a one-time trial credit (no card required to start). That's more than enough to build, test, and demo this project.

Step 1: From a script to a service

Our original chatbot.js runs three hardcoded questions and exits. That's fine for a terminal demo, but a container that exits immediately looks "crashed" to any hosting platform. We need something that keeps listening.

We also want something more interesting to look at than raw text in a browser tab — so this version serves a small web interface instead, with the same live-streaming effect visible on screen.

Create server.js (this sits alongside chatbot.js, which stays untouched):

require("dotenv").config();
const http = require("http");
const fs = require("fs");
const path = require("path");
const OpenAI = require("openai");

const client = new OpenAI({
  apiKey: process.env.GROQ_API_KEY,
  baseURL: "https://api.groq.com/openai/v1",
});

const MODEL = "openai/gpt-oss-120b";
const PORT = process.env.PORT || 3000;

const indexHtml = fs.readFileSync(path.join(__dirname, "public", "index.html"));

function readBody(req) {
  return new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => resolve(data));
    req.on("error", reject);
  });
}

const server = http.createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/") {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.end(indexHtml);
    return;
  }

  if (req.method === "POST" && req.url === "/chat") {
    const body = await readBody(req);
    let userMessage = "Hello!";
    try {
      userMessage = JSON.parse(body).message || userMessage;
    } catch (_) {}

    res.writeHead(200, {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache",
    });

    try {
      const stream = await client.chat.completions.create({
        model: MODEL,
        stream: true,
        messages: [{ role: "user", content: userMessage }],
      });

      for await (const chunk of stream) {
        const text = chunk.choices[0]?.delta?.content || "";
        if (text) res.write(text);
      }
      res.end();
    } catch (error) {
      res.end(`[error] ${error.message}`);
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain" });
  res.end("Not found");
});

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

The / route serves a small HTML page (public/index.html) with an input box and a scrolling log; the /chat route is a streaming endpoint the page calls with fetch, reading the response body chunk by chunk with a ReadableStream — the same streaming pattern from Part 1, just moved from a terminal for await loop to a browser one. The full front-end code (a single self-contained HTML file, no build step) is in the GitHub repo.

Also add a start script in package.json:

"scripts": {
  "start": "node server.js"
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Write the Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --omit=dev

COPY . .

ENV PORT=3000
EXPOSE 3000

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

And a .dockerignore, so the image doesn't ship your local dependencies or secrets:

node_modules
.env
.git
.gitignore
Enter fullscreen mode Exit fullscreen mode

Step 3: Build and test locally

docker build -t llm-chatbot .
docker run -p 3000:3000 --env-file .env llm-chatbot
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 — you should see the web interface, and streamed responses when you send a message.
Common snag: if you change your code and rebuild, but still see the old behavior, stop any running containers first (docker stop $(docker ps -q)) before rebuilding — an old container can still be holding the port.

Step 4: Publish to Docker Hub

docker login
docker tag llm-chatbot <your-username>/llm-chatbot-tutorial:latest
docker push <your-username>/llm-chatbot-tutorial:latest
Enter fullscreen mode Exit fullscreen mode

Check hub.docker.com/r//llm-chatbot-tutorial — your image should be listed there, publicly.

Step 5: Deploy to Railway
Railway's dashboard flow for connecting a Docker Hub image can be inconsistent about actually triggering the first deploy — the CLI is the more reliable path:

npm install -g @railway/cli
railway login
railway link --project <your-project-name> --environment production --service <your-service-name>
railway up
Enter fullscreen mode Exit fullscreen mode

railway up builds directly from your Dockerfile and deploys it — no dependency on the Docker Hub push actually triggering anything.

Set your API key as a variable (via the dashboard's Variables tab, or railway variables set GROQ_API_KEY=your_key), and generate a public domain from Settings → Networking → Generate Domain.

Common snag: if the generated domain gives "Application failed to respond," check the port. Railway assigns its own internal port to your container (visible in the deploy logs, e.g. "Server listening on port 8080") — make sure the port you entered when generating the domain matches that, not the 3000 you used locally.

Step 6: Confirm it's live
Visit your Railway-provided URL (something like your-project.up.railway.app) — you should see the same interface, publicly reachable, streaming responses in real time.

Conclusion
We took a terminal script and turned it into a containerized, publicly deployed web service — the same pattern used to ship real backend services. From here: adding a database, rate-limiting, or a CI/CD pipeline that deploys automatically on every push — which is exactly what we'll build next.

Next in this series: automating tests and deploys with GitHub Actions.

Top comments (0)