The divergence between a local development setup and a containerized production environment is a persistent source of friction. Developers running services directly on their host machine (dotnet run
, npm run dev
) benefit from rapid feedback loops, particularly hot-reloading, but operate in an environment that bears little resemblance to the final deployment target. This gap introduces “it works on my machine” bugs, complicates dependency management, and makes onboarding new team members a project-specific ritual. The standard response—using containers for development—often sacrifices the immediate feedback that makes local development productive. File system event propagation can be unreliable, network configurations become complex, and the seamless experience of instant code changes reflecting in the browser is lost.
Our objective was to bridge this gap: to create a development environment that is fully containerized and mirrors production topology, yet retains the sub-second hot-reloading capabilities for both the frontend and backend. The core concept was to leverage Podman’s Pod primitive, a group of containers sharing resources like a network namespace, to create a high-fidelity, isolated “development pod.” This pod would house our Vite frontend dev server and our ASP.NET Core Web API, allowing them to communicate over localhost
as if they were native processes, all while their source code resides on the host machine for editing in a standard IDE.
The choice of Podman over more traditional tools like Docker Compose was deliberate. Its daemonless architecture reduces overhead and tightens integration with systemd. More importantly, its rootless-by-default security model is a significant advantage in multi-user or CI environments. The first-class Pod concept provides a direct analogue to a Kubernetes Pod, meaning our development environment’s architecture is already aligned with modern orchestration standards, reducing surprises during deployment. For the stack, ASP.NET Core was selected for its mature and high-performance dotnet watch
hot-reload capability, and Vite for its unparalleled Hot Module Replacement (HMR) speed, which is non-negotiable for frontend productivity.
Project Foundation and Directory Structure
Before containerizing, we establish a clean project structure. This separation is critical for managing distinct build contexts and volume mounts later.
/dev-pod-project
├── scripts/
│ └── dev-env.sh
├── src/
│ ├── api/
│ │ ├── Api.csproj
│ │ ├── Program.cs
│ │ ├── Controllers/
│ │ │ └── WeatherForecastController.cs
│ │ └── Containerfile
│ └── frontend/
│ ├── package.json
│ ├── vite.config.js
│ ├── index.html
│ ├── src/
│ │ └── App.jsx
│ └── Containerfile
└── .gitignore
The API is a standard ASP.NET Core Web API project, created via dotnet new webapi -n Api
. The frontend is a standard Vite project, initialized with npm create vite@latest frontend -- --template react
.
The initial API controller provides a simple endpoint for testing.
src/api/Controllers/WeatherForecastController.cs
:
using Microsoft.AspNetCore.Mvc;
namespace Api.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
_logger.LogInformation("Generating new weather forecast at {Timestamp}", DateTime.UtcNow);
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
The frontend application will fetch data from this endpoint.
src/frontend/src/App.jsx
:
import { useState, useEffect } from 'react';
import './App.css';
function App() {
const [forecast, setForecast] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchForecast() {
try {
// This relative URL will be proxied by Vite's dev server
const response = await fetch('/weatherforecast');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setForecast(data);
} catch (e) {
console.error("Failed to fetch:", e);
setError(`Failed to load data. Is the API running? Error: ${e.message}`);
} finally {
setLoading(false);
}
}
fetchForecast();
}, []);
return (
<div className="App">
<h1>Development Pod Demo</h1>
<h2>API Weather Forecast</h2>
{loading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
{forecast && (
<table>
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{forecast.map((f, index) => (
<tr key={index}>
<td>{f.date}</td>
<td>{f.temperatureC}</td>
<td>{f.summary}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
export default App;
Crafting the Development Containerfiles
The key to enabling hot-reload within containers is to not copy the source code into the image during the build. Instead, the image should contain the runtime and dependencies, while the source code is provided at runtime via a volume mount from the host. This approach is specific to development; production images would use a multi-stage build to create a lean image with compiled artifacts.
Backend API Containerfile
This Containerfile
is optimized for a development workflow using dotnet watch
.
src/api/Containerfile
:
# Use the official .NET SDK image as the base.
# This image contains the complete SDK and runtime needed for 'dotnet watch'.
FROM mcr.microsoft.com/dotnet/sdk:8.0
# Set up a working directory. This path will be the target for our volume mount.
WORKDIR /app
# Expose the port the API will listen on inside the container.
# This is documentation; the actual port mapping happens at the pod level.
EXPOSE 8080
# The following environment variable is critical for dotnet watch to detect file changes
# when running in some container environments or virtual machines where file system events
# don't propagate correctly. It forces the watcher to use a slower but more reliable polling mechanism.
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
# Another crucial setting for running inside a container. By default, ASP.NET Core
# might try to bind to localhost, which is inaccessible from outside the container.
# Binding to http://+:8080 makes it listen on all network interfaces within the container's network namespace.
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Development
# The command to run when the container starts.
# 'dotnet watch' will start the application, monitor the source files for changes,
# and automatically restart the app when a change is detected.
# The project path needs to be specified since we run this from the parent WORKDIR.
CMD ["dotnet", "watch", "run", "--project", "./Api.csproj"]
A common mistake here is to add a COPY . .
and RUN dotnet restore
step. For a development image, this is counterproductive. Every code change would require a rebuild. Instead, we will mount the project directory directly, and dotnet watch
will handle dependency restoration and compilation on first run and subsequent changes.
Frontend Vite Containerfile
The frontend Containerfile
follows a similar philosophy. It installs dependencies but expects the source code to be mounted.
src/frontend/Containerfile
:
# Use a Node.js base image. A specific LTS version is good practice.
FROM node:20-alpine
# Set the working directory inside the container.
WORKDIR /app
# Copy only the package.json and package-lock.json files.
# This allows Podman/Docker to cache the dependency installation layer
# as long as these files don't change, speeding up rebuilds significantly.
COPY package*.json ./
# Install project dependencies.
RUN npm install
# Expose the default Vite dev server port.
EXPOSE 5173
# The command that starts the Vite development server.
# `npm run dev` is the standard command. The `-- --host` part is critical.
# It's an argument passed to Vite itself, telling it to bind to 0.0.0.0.
# This makes the dev server accessible from outside its own container
# within the shared pod network. Without it, it would bind to localhost
# and be unreachable by the host machine or other containers.
CMD ["npm", "run", "dev", "--", "--host"]
Orchestrating the Pod with a Management Script
Managing the lifecycle of the pod and its containers—creation, startup, shutdown, and cleanup—is best handled by a script. This ensures consistency and reproducibility.
scripts/dev-env.sh
:
#!/bin/bash
set -e
# --- Configuration ---
POD_NAME="dev-pod"
API_IMAGE_NAME="dev-api-image"
FRONTEND_IMAGE_NAME="dev-frontend-image"
API_CONTAINER_NAME="api-dev-container"
FRONTEND_CONTAINER_NAME="frontend-dev-container"
# Ports mapped from HOST to POD
# Format: HOST_PORT:POD_PORT
HOST_API_PORT=5001
HOST_FRONTEND_PORT=5173
# Ports used inside the pod (must match Containerfile EXPOSE and app configs)
POD_API_PORT=8080
POD_FRONTEND_PORT=5173
# Project root relative to the script location
PROJECT_ROOT=$(dirname "$0")/..
# --- Helper Functions ---
info() {
echo "[INFO] $1"
}
error() {
echo "[ERROR] $1" >&2
exit 1
}
# --- Main Logic ---
start() {
info "Starting development environment..."
# Check if pod already exists
if podman pod exists "$POD_NAME"; then
info "Pod '$POD_NAME' already exists. Reusing it."
else
info "Creating pod '$POD_NAME'..."
podman pod create --name "$POD_NAME" \
-p "${HOST_FRONTEND_PORT}:${POD_FRONTEND_PORT}" \
-p "${HOST_API_PORT}:${POD_API_PORT}"
fi
info "Building API image ('$API_IMAGE_NAME')..."
podman build -t "$API_IMAGE_NAME" -f "$PROJECT_ROOT/src/api/Containerfile" "$PROJECT_ROOT/src/api"
info "Building frontend image ('$FRONTEND_IMAGE_NAME')..."
podman build -t "$FRONTEND_IMAGE_NAME" -f "$PROJECT_ROOT/src/frontend/Containerfile" "$PROJECT_ROOT/src/frontend"
info "Starting API container..."
# A pitfall here is file permissions on volume mounts, especially with SELinux.
# The ':Z' flag tells Podman to relabel the content so it's accessible to the container.
# This is a production-grade consideration often missed in simple tutorials.
podman run -d --pod "$POD_NAME" --name "$API_CONTAINER_NAME" \
-v "$PROJECT_ROOT/src/api:/app:Z" \
"$API_IMAGE_NAME"
info "Starting frontend container..."
podman run -d --pod "$POD_NAME" --name "$FRONTEND_CONTAINER_NAME" \
-v "$PROJECT_ROOT/src/frontend:/app:Z" \
"$FRONTEND_IMAGE_NAME"
info "Development environment is up!"
info "Frontend available at: http://localhost:${HOST_FRONTEND_PORT}"
info "API available at: http://localhost:${HOST_API_PORT}"
info "Run './dev-env.sh logs' to see output."
}
stop() {
info "Stopping development environment..."
if podman pod exists "$POD_NAME"; then
podman pod stop "$POD_NAME"
info "Pod '$POD_NAME' stopped."
else
info "Pod '$POD_NAME' not found."
fi
}
destroy() {
info "Destroying development environment..."
if podman pod exists "$POD_NAME"; then
podman pod rm -f "$POD_NAME"
info "Pod '$POD_NAME' removed."
else
info "Pod '$POD_NAME' not found."
fi
# Optional: remove images if you want a full clean
# podman rmi -f "$API_IMAGE_NAME" "$FRONTEND_IMAGE_NAME"
}
logs() {
info "Streaming logs for both containers (Ctrl+C to stop)..."
# The '-f' flag follows the log output
podman logs -f "$API_CONTAINER_NAME" "$FRONTEND_CONTAINER_NAME"
}
# --- Script Entrypoint ---
case "$1" in
start)
start
;;
stop)
stop
;;
destroy)
destroy
;;
logs)
logs
;;
*)
echo "Usage: $0 {start|stop|destroy|logs}"
exit 1
esac
This script provides a clean interface (start
, stop
, destroy
, logs
) for the entire environment. A crucial detail is the :Z
suffix on the volume mounts (-v
). On SELinux-enabled systems like Fedora or RHEL, containers are blocked from accessing host volumes by default. The :Z
flag instructs Podman to relabel the host directory, making it accessible to the container process, solving a common and frustrating point of failure.
Enabling Communication Within the Pod
The final piece is configuring the Vite dev server to proxy API requests to the backend container. Because both containers are in the same pod, they share a network namespace. The frontend container can reach the API container simply by calling localhost
on the API’s internal port (8080
). This greatly simplifies configuration and perfectly mimics a sidecar pattern in Kubernetes.
src/frontend/vite.config.js
:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
// This proxy is the magic that connects frontend to backend.
// Any request from the React app to '/weatherforecast' will be
// forwarded by the Vite dev server to our .NET API container.
proxy: {
'/weatherforecast': {
// Because both containers are in the same pod, they can communicate
// over localhost. We target the internal port of the API container.
target: 'http://localhost:8080',
changeOrigin: true, // Recommended for virtual hosts
secure: false, // Don't validate SSL certs if API were HTTPS
}
}
}
})
The Unified Hot-Reload Experience
With all components in place, the workflow becomes remarkably simple:
- Start the environment:
./scripts/dev-env.sh start
- View the application: Open
http://localhost:5173
in a browser. The frontend loads and successfully fetches data from the API. - Backend Change: Modify the
Summaries
array inWeatherForecastController.cs
on the host machine. Save the file. Watching the logs (./scripts/dev-env.sh logs
), you will seedotnet watch
detect the change and restart the API service within seconds. Refreshing the browser shows the new summary values. - Frontend Change: Modify the
<h1>
tag inApp.jsx
on the host. Save the file. The browser will update the heading instantly via Vite’s HMR without a full page reload.
This demonstrates a successful implementation: a fully isolated, containerized environment that provides the same high-speed feedback loop as a native local setup.
graph TD subgraph Host Machine A[Developer's IDE] -- Edits files --> B{src/api}; A -- Edits files --> C{src/frontend}; D[Browser on localhost:5173] --> E[Podman]; end subgraph Podman subgraph "dev-pod (Shared Network: localhost)" F[Frontend Container] -- Serves --> G[Port 5173]; H[API Container] -- Serves --> I[Port 8080]; F -- API Request to /weatherforecast --> J[Vite Proxy]; J -- Forwards to http://localhost:8080 --> H; end subgraph Volume Mounts B -- :Z --> H; C -- :Z --> F; end subgraph Port Mappings G -- Mapped to --> K[Host Port 5173]; I -- Mapped to --> L[Host Port 5001]; end E -- Manages Pod --> dev-pod; end K --> D;
This architecture effectively solves the initial problem. The development environment is now defined entirely in code (Containerfile
s, dev-env.sh
), making it reproducible and portable. The use of a Podman Pod simplifies networking to the point of triviality while preparing the application for a Kubernetes-native future.
The current implementation, while robust for a single developer’s workflow, does not address database integration. A logical next step is to add a database container (e.g., PostgreSQL) to the pod, managing its state with a named Podman volume to ensure data persistence across pod restarts. Furthermore, while the development build process is optimized for speed, the initial image builds can be slow if dependencies change. Advanced Containerfile
layering and leveraging build caches in a CI environment would be necessary for team-wide adoption. This development pod is not a production artifact; the production deployment would leverage multi-stage builds to create minimalist runtime images, stripping out SDKs and development tooling to reduce the attack surface and image size.