- Prerequisites
- Project Structure
- Setup Instructions
- Backend Development
- Frontend Development
- Podman Containerization
- Running the Application
- Running Commands in Containers
- Debugging with Podman
- Podman PostgreSQL Guide
- Troubleshooting
- Next Steps
- Resources
Before starting, ensure you have the following installed on your macOS:
- Node.js (v18 or higher) - Download
- Docker Desktop - Download
- Podman - Download
- Code Editor (VS Code recommended) - Download
- Terminal (built-in Terminal or iTerm2)
Verify installations:
node --version
npm --version
# docker --version
# docker-compose --version
podman --version
podman compose --versionClick to view project file tree and architecture
Here's the complete file tree for our application:
user-management-app/
├── backend/
│ ├── src/
│ │ ├── repositories/
│ │ │ ├── BaseRepository.ts
│ │ │ └── UserRepository.ts
│ │ ├── routes/
│ │ │ └── users.ts
│ │ ├── types/
│ │ │ └── User.ts
│ │ └── server.ts
│ ├── prisma/
│ │ └── schema.prisma
│ ├── Containerfile.dev
│ ├── Containerfile.prod
│ ├── .containerignore
│ ├── package.json
│ ├── tsconfig.json
│ └── .env
├── frontend/
│ ├── src/
│ │ ├── repositories/
│ │ │ ├── BaseRepository.ts
│ │ │ └── UserRepository.ts
│ │ ├── components/
│ │ │ ├── UserList.tsx
│ │ │ └── UserForm.tsx
│ │ ├── viewmodels/
│ │ │ └── UserManagementViewModel.ts
│ │ ├── types/
│ │ │ └── User.ts
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── Containerfile.dev
│ ├── Containerfile.prod
│ ├── .containerignore
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── index.html
│ └── .env
├── compose.dev.yml
├── compose.prod.yml
└── README.md
This structure follows the Repository Pattern - a design pattern that separates data access logic from business logic, making code more maintainable and testable.
Click to view initial project setup steps
mkdir user-management-app
cd user-management-appmkdir -p backend/src/{repositories,routes,types}
mkdir -p backend/prisma
cd backend
npm init -yInstall backend dependencies:
npm install express cors dotenv @prisma/client
npm install -D typescript @types/node @types/express @types/cors ts-node nodemon prismacd ..
npm create vite@latest frontend -- --template react-ts
cd frontend
npm installInstall Chakra UI and dependencies:
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motionChakra UI is a modern React component library that provides accessible, customizable components with built-in theming support.
Click to view configuration and Prisma setup
Update backend/package.json:
{
"scripts": {
"dev": "nodemon --legacy-watch --exec ts-node src/app.ts",
...
}
}Create backend/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}Create backend/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
role String @default("user")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Prisma is an ORM (Object-Relational Mapping) tool that provides a type-safe way to interact with your database. The schema above defines a
Usermodel that will create a corresponding table in PostgreSQL.
Create backend/.env:
DATABASE_URL="postgresql://devuser:devpassword@postgres:5432/main_db?schema=public"
PORT=3000In production, never commit
.envfiles! Add them to.gitignore.
Click to view types and repositories (Repository Pattern)
Create backend/src/types/User.ts:
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
createdAt?: Date;
updatedAt?: Date;
}Create backend/src/repositories/BaseRepository.ts:
import { PrismaClient } from '@prisma/client';
export abstract class BaseRepository<T extends { id: number }> {
protected prisma: PrismaClient;
protected modelName: string;
constructor(prisma: PrismaClient, modelName: string) {
this.prisma = prisma;
this.modelName = modelName;
}
async getAllAsync(): Promise<T[]> {
const model = (this.prisma as any)[this.modelName];
return await model.findMany();
}
async getByIdAsync(id: number): Promise<T | null> {
const model = (this.prisma as any)[this.modelName];
return await model.findUnique({
where: { id }
});
}
async createAsync(data: Omit<T, 'id'>): Promise<T> {
const model = (this.prisma as any)[this.modelName];
return await model.create({
data
});
}
async updateAsync(id: number, data: Partial<T>): Promise<T> {
const model = (this.prisma as any)[this.modelName];
return await model.update({
where: { id },
data
});
}
async deleteAsync(id: number): Promise<void> {
const model = (this.prisma as any)[this.modelName];
await model.delete({
where: { id }
});
}
}Create backend/src/repositories/UserRepository.ts:
import { PrismaClient } from '@prisma/client';
import { BaseRepository } from './BaseRepository';
import { User } from '../types/User';
export class UserRepository extends BaseRepository<User> {
constructor(prisma: PrismaClient) {
super(prisma, 'user');
}
async findByEmailAsync(email: string): Promise<User | null> {
return await this.prisma.user.findUnique({
where: { email }
});
}
async findByRoleAsync(role: 'user' | 'admin'): Promise<User[]> {
return await this.prisma.user.findMany({
where: { role }
});
}
async promoteToAdminAsync(id: number): Promise<User> {
return await this.prisma.user.update({
where: { id },
data: { role: 'admin' }
});
}
}Click to view API routes and Express server setup
Create backend/src/routes/users.ts:
import { Router, Request, Response } from 'express';
import { UserRepository } from '../repositories/UserRepository';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
const userRepository = new UserRepository(prisma);
// GET /api/users - Get all users
router.get('/', async (req: Request, res: Response) => {
try {
const users = await userRepository.getAllAsync();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// GET /api/users/:id - Get user by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const user = await userRepository.getByIdAsync(Number(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// POST /api/users - Create new user
router.post('/', async (req: Request, res: Response) => {
try {
const { name, email, role } = req.body;
const user = await userRepository.createAsync({ name, email, role: role || 'user' });
res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to create user' });
}
});
// PUT /api/users/:id - Update user
router.put('/:id', async (req: Request, res: Response) => {
try {
const user = await userRepository.updateAsync(Number(req.params.id), req.body);
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to update user' });
}
});
// DELETE /api/users/:id - Delete user
router.delete('/:id', async (req: Request, res: Response) => {
try {
await userRepository.deleteAsync(Number(req.params.id));
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete user' });
}
});
// POST /api/users/:id/promote - Promote user to admin
router.post('/:id/promote', async (req: Request, res: Response) => {
try {
const user = await userRepository.promoteToAdminAsync(Number(req.params.id));
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to promote user' });
}
});
export default router;Create backend/src/server.ts:
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import userRoutes from './routes/users';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/users', userRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});Click to view Prisma commands and Podman Containerfiles
Add these scripts to backend/package.json:
{
"scripts": {
"dev": "nodemon --legacy-watch --exec ts-node src/app.ts"
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:push": "prisma db push"
}
}Required if backend/prisma/schema.prisma was modified.
- Capture Changes (Local dev): Required after editing the schema:
npx prisma migrate dev --name <migration_name>
- Apply Changes (Production): Required on the production DB to apply pending migrations:
npx prisma migrate deploy
- Regenerate Client: Required always after schema changes to update types:
npx prisma generate
Key Prisma Commands:
npm run prisma:migrate(prisma migrate dev): Creates a new migration file from your schema changes and applies it. Use this in development.npm run prisma:deploy(prisma migrate deploy): Applies all pending migrations to the database. It does not create new migration files. Use this in production or CI/CD pipelines to safely update your production database.npm run prisma:push(prisma db push): Rapidly syncs your schema with the database without generating migration history. Great for prototyping, but avoid in production.
We use separate configurations for development and production to optimize build times and image sizes.
Optimized for developer productivity with hot-reloading.
# THE "HEAVYWEIGHT": Includes everything. Best if your app needs to compile C++ modules.
# FROM node:20 AS build
# THE "LIGHTWEIGHT": Ultra-light and secure. Use this by default for 99% of web apps.
FROM node:20-alpine
WORKDIR /app
# Install dependencies for Prisma
RUN apk add --no-cache openssl libc6-compat
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm install
# Generate Prisma Client
RUN npx prisma generate
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start command (Development with hot-reload)
CMD ["sh", "-c", "npm install && npx prisma db push && npm run dev"]Optimized for production with a multi-stage build.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
COPY package*.json ./
COPY prisma ./prisma/
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat
# Only install production dependencies
COPY package*.json ./
COPY prisma ./prisma/
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["sh", "-c", "npm install && npx prisma db push && npm start"]Create backend/.containerignore (Used by Podman as well):
node_modules
dist
.env
Click to view configuration, types, and base repository
Update frontend/vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Listen on 0.0.0.0
host: true,
// Required for Hot Module Replacement (HMR) to work in Podman on macOS
watch: {
usePolling: true
}
}
})Create frontend/src/types/User.ts:
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
createdAt?: string;
updatedAt?: string;
}Create frontend/src/repositories/BaseRepository.ts:
// Base generic repository for client-side data fetching
export abstract class BaseRepository<T extends { id: number }> {
protected baseUrl: string = '';
async getAllAsync(): Promise<T[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) throw new Error(`Failed to fetch items`);
return response.json();
}
async getByIdAsync(id: number): Promise<T> {
const response = await fetch(`${this.baseUrl}/${id}`);
if (!response.ok) throw new Error(`Item ${id} not found`);
return response.json();
}
async createAsync(item: Omit<T, 'id'>): Promise<T> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
if (!response.ok) throw new Error('Failed to create item');
return response.json();
}
async updateAsync(item: T): Promise<T> {
const response = await fetch(`${this.baseUrl}/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
if (!response.ok) throw new Error('Failed to update item');
return response.json();
}
async deleteAsync(id: number): Promise<void> {
const response = await fetch(`${this.baseUrl}/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete item');
}
}Click to view state management (Repository and ViewModel Patterns)
Create frontend/src/repositories/UserRepository.ts:
import { BaseRepository } from './BaseRepository';
import { User } from '../types/User';
export class UserRepository extends BaseRepository<User> {
constructor() {
super();
this.baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/users';
}
async findByEmailAsync(email: string): Promise<User | null> {
const users = await this.getAllAsync();
return users.find(u => u.email === email) || null;
}
async findByRoleAsync(role: 'user' | 'admin'): Promise<User[]> {
const users = await this.getAllAsync();
return users.filter(u => u.role === role);
}
async promoteToAdminAsync(id: number): Promise<User> {
const response = await fetch(`${this.baseUrl}/${id}/promote`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to promote user');
return response.json();
}
}Create frontend/src/viewmodels/UserManagementViewModel.ts:
import { UserRepository } from '../repositories/UserRepository';
import { User } from '../types/User';
export class UserManagementViewModel {
private repository: UserRepository;
private users: User[] = [];
private isLoading: boolean = false;
private error: string | null = null;
private listeners: (() => void)[] = [];
constructor() {
this.repository = new UserRepository();
}
// Subscribe to state changes
subscribe(listener: () => void): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
private notify(): void {
this.listeners.forEach(listener => listener());
}
async initializeAsync(): Promise<void> {
try {
this.isLoading = true;
this.notify();
this.users = await this.repository.getAllAsync();
this.error = null;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Initialization failed';
} finally {
this.isLoading = false;
this.notify();
}
}
async createUserAsync(name: string, email: string): Promise<void> {
try {
this.isLoading = true;
this.notify();
const newUser = await this.repository.createAsync({
name,
email,
role: 'user'
});
this.users.push(newUser);
this.error = null;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to create user';
throw err;
} finally {
this.isLoading = false;
this.notify();
}
}
async updateUserAsync(user: User): Promise<void> {
try {
this.isLoading = true;
this.notify();
const updated = await this.repository.updateAsync(user);
const index = this.users.findIndex(u => u.id === user.id);
if (index !== -1) {
this.users[index] = updated;
}
this.error = null;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to update user';
throw err;
} finally {
this.isLoading = false;
this.notify();
}
}
async deleteUserAsync(id: number): Promise<void> {
try {
this.isLoading = true;
this.notify();
await this.repository.deleteAsync(id);
this.users = this.users.filter(u => u.id !== id);
this.error = null;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to delete user';
throw err;
} finally {
this.isLoading = false;
this.notify();
}
}
async promoteToAdminAsync(id: number): Promise<void> {
const user = this.users.find(u => u.id === id);
if (!user) throw new Error('User not found');
user.role = 'admin';
await this.updateUserAsync(user);
}
getUsers(): User[] {
return this.users;
}
async getAdmins(): Promise<User[]> {
return this.repository.findByRoleAsync('admin');
}
getError(): string | null {
return this.error;
}
getIsLoading(): boolean {
return this.isLoading;
}
}Click to view React components (Form, List, and Main App)
Create frontend/src/components/UserForm.tsx:
import { useState } from 'react';
import {
Box,
FormControl,
FormLabel,
Input,
Button,
Heading,
VStack,
} from '@chakra-ui/react';
interface UserFormProps {
onSubmit: (name: string, email: string) => Promise<void>;
}
export const UserForm: React.FC<UserFormProps> = ({ onSubmit }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(name, email);
setName('');
setEmail('');
} finally {
setIsSubmitting(false);
}
};
return (
<Box
as="form"
onSubmit={handleSubmit}
bg="white"
p={8}
borderRadius="xl"
boxShadow="xl"
>
<VStack spacing={6} align="stretch">
<Heading size="lg" color="purple.600">
Add New User
</Heading>
<FormControl isRequired>
<FormLabel>Name</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
size="lg"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
size="lg"
/>
</FormControl>
<Button
type="submit"
colorScheme="purple"
size="lg"
isLoading={isSubmitting}
loadingText="Adding..."
>
Add User
</Button>
</VStack>
</Box>
);
};Create frontend/src/components/UserList.tsx:
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Badge,
Heading,
Text,
HStack,
useDisclosure,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
} from '@chakra-ui/react';
import { useRef, useState } from 'react';
import { User } from '../types/User';
interface UserListProps {
users: User[];
onDelete: (id: number) => Promise<void>;
onPromote: (id: number) => Promise<void>;
}
export const UserList: React.FC<UserListProps> = ({ users, onDelete, onPromote }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const handleDeleteClick = (id: number) => {
setSelectedUserId(id);
onOpen();
};
const handleConfirmDelete = async () => {
if (selectedUserId !== null) {
await onDelete(selectedUserId);
onClose();
setSelectedUserId(null);
}
};
return (
<Box bg="white" p={8} borderRadius="xl" boxShadow="xl">
<Heading size="lg" mb={6} color="purple.600">
User Management
</Heading>
{users.length === 0 ? (
<Text color="gray.500" textAlign="center" py={8} fontStyle="italic">
No users found. Add your first user!
</Text>
) : (
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((user) => (
<Tr key={user.id}>
<Td>{user.id}</Td>
<Td fontWeight="medium">{user.name}</Td>
<Td color="gray.600">{user.email}</Td>
<Td>
<Badge
colorScheme={user.role === 'admin' ? 'pink' : 'blue'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{user.role.toUpperCase()}
</Badge>
</Td>
<Td>
<HStack spacing={2}>
{user.role === 'user' && (
<Button
size="sm"
colorScheme="green"
onClick={() => onPromote(user.id)}
>
Promote
</Button>
)}
<Button
size="sm"
colorScheme="red"
onClick={() => handleDeleteClick(user.id)}
>
Delete
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete User
</AlertDialogHeader>
<AlertDialogBody>
Are you sure you want to delete this user? This action cannot be undone.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={handleConfirmDelete} ml={3}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</Box>
);
};Create frontend/src/App.tsx:
import { useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
Alert,
AlertIcon,
Spinner,
Center,
SimpleGrid,
} from '@chakra-ui/react';
import { UserManagementViewModel } from './viewmodels/UserManagementViewModel';
import { UserList } from './components/UserList';
import { UserForm } from './components/UserForm';
const viewModel = new UserManagementViewModel();
function App() {
const [, setUpdate] = useState(0);
useEffect(() => {
const unsubscribe = viewModel.subscribe(() => {
setUpdate(prev => prev + 1);
});
viewModel.initializeAsync();
return unsubscribe;
}, []);
const handleCreateUser = async (name: string, email: string) => {
await viewModel.createUserAsync(name, email);
};
const handleDeleteUser = async (id: number) => {
await viewModel.deleteUserAsync(id);
};
const handlePromoteUser = async (id: number) => {
await viewModel.promoteToAdminAsync(id);
};
const users = viewModel.getUsers();
const isLoading = viewModel.getIsLoading();
const error = viewModel.getError();
return (
<Box minH="100vh" bgGradient="linear(to-br, purple.600, purple.800)" py={10}>
<Container maxW="container.xl">
<VStack spacing={8} align="stretch">
<Box textAlign="center" color="white">
<Heading as="h1" size="2xl" mb={2} textShadow="2px 2px 4px rgba(0,0,0,0.2)">
User Management System
</Heading>
<Text fontSize="xl" opacity={0.9}>
Full-Stack React + Prisma + PostgreSQL
</Text>
</Box>
{error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
{error}
</Alert>
)}
{isLoading && users.length === 0 ? (
<Center py={20}>
<VStack>
<Spinner size="xl" color="white" thickness="4px" />
<Text color="white">Loading...</Text>
</VStack>
</Center>
) : (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8}>
<Box gridColumn={{ md: "span 1" }}>
<UserForm onSubmit={handleCreateUser} />
</Box>
<Box gridColumn={{ md: "span 2" }}>
<UserList
users={users}
onDelete={handleDeleteUser}
onPromote={handlePromoteUser}
/>
</Box>
</SimpleGrid>
)}
</VStack>
</Container>
</Box>
);
}
export default App;Click to view entry point, environment, and Podman Containerfiles
Update frontend/src/main.tsx to include Chakra Provider:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ChakraProvider } from '@chakra-ui/react';
import App from './App';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ChakraProvider>
<App />
</ChakraProvider>
</React.StrictMode>
);Create frontend/.env:
VITE_API_URL=http://localhost:3000/api/usersUses the Vite development server with hot-reloading.
# THE "HEAVYWEIGHT": Includes everything. Best if your app needs to compile C++ modules.
# FROM node:20 AS build
# THE "LIGHTWEIGHT": Ultra-light and secure. Use this by default for 99% of web apps.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Expose Vite dev port
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]Compiles the app and serves it via Nginx.
# Stage 1: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Create frontend/nginx.conf:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}Create frontend/.containerignore (Used by Podman as well):
node_modules
dist
.env
Click to view Podman Compose development and production configurations
We use two separate compose files to distinguish between development and production environments.
Includes volume mounts for hot-reloading and development secrets.
services:
# PostgreSQL
postgres:
# THE "HEAVYWEIGHT": Based on Debian. Best for production. Handles different languages and sorting (locales) perfectly.
# image: postgres:17
# THE "LIGHTWEIGHT": Based on Alpine. Fast and tiny, but text sorting might behave differently (uses musl instead of glibc).
image: postgres:17-alpine
container_name: postgres-db
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: main_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d main_db" ]
interval: 10s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Containerfile.dev
container_name: user-api
environment:
DATABASE_URL: postgresql://devuser:devpassword@postgres:5432/main_db?schema=public
PORT: 3000
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
- /app/node_modules
# Frontend React App
frontend:
build:
context: ./frontend
dockerfile: Containerfile.dev
container_name: user-frontend
environment:
VITE_API_BASE_URL: http://localhost:3000/api
ports:
- "5173:5173"
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
volumes:
pgdata:
driver: localOptimized for deployment, pulling secrets from environment variables.
services:
# PostgreSQL
postgres:
image: postgres:17-alpine
container_name: postgres-db-prod
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-produser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-prodpassword}
POSTGRES_DB: ${POSTGRES_DB:-prod_db}
ports:
- "5432:5432"
volumes:
- pgdata_prod:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
interval: 10s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Containerfile.prod
container_name: user-api-prod
restart: always
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
PORT: 3000
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
# Frontend (Served via Nginx)
frontend:
build:
context: ./frontend
dockerfile: Containerfile.prod
container_name: user-frontend-prod
restart: always
ports:
- "80:80"
depends_on:
- backend
volumes:
pgdata_prod:
driver: localWhat does Podman/Docker Compose do?
- Defines and runs multi-container applications
- Manages networks between containers
- Handles startup dependencies
- Simplifies environment configuration
Click to view commands to start, test, and stop the application
Ensure your Podman machine is running (if on macOS):
podman machine startFrom the project root directory:
For Development:
podman compose -f compose.dev.yml up --buildFor Production:
podman compose -f compose.prod.yml up -d --buildThe
--buildflag rebuilds images. After the first build, you can use justpodman compose up.
The development configuration maps local directories to containers for hot-reloading. Changes made locally will instantly reflect in the running container.
Backend (Dev):
podman build -t user-api-dev -f backend/Containerfile.dev ./backendBackend (Prod):
podman build -t user-api-prod -f backend/Containerfile.prod ./backendFrontend (Dev):
podman build -t user-frontend-dev -f frontend/Containerfile.dev ./frontendFrontend (Prod):
podman build -t user-frontend-prod -f frontend/Containerfile.prod ./frontendThe compose.dev.yml file defaults to dev credentials. For compose.prod.yml, the engine will pull values from your shell or a local .env file for:
POSTGRES_USERPOSTGRES_PASSWORDPOSTGRES_DB
- Frontend (Dev): http://localhost:5173
- Frontend (Prod): http://localhost:80
- Backend API: http://localhost:3000
- Health Check: http://localhost:3000/health
Using curl:
# Create a user
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
# Get all users
curl http://localhost:3000/api/users
# Get user by ID
curl http://localhost:3000/api/users/1
# Update user
curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"John Updated","email":"john@example.com","role":"user"}'
# Promote to admin
curl -X POST http://localhost:3000/api/users/1/promote
# Delete user
curl -X DELETE http://localhost:3000/api/users/1# docker-compose down
podman compose downTo remove volumes (deletes database data):
# docker-compose down -v
podman compose down -vClick to view guide for running commands inside containers
Sometimes you need to monitor your containers or execute commands directly inside them.
To see which containers are currently running, use podman ps.
# List only running containers
# docker ps
podman ps
# List ALL containers (including stopped ones)
# docker ps -a
podman ps -aUse podman exec to run a command in an already running container.
# Syntax: podman exec [options] <container_name_or_id> <command>
podman exec -it <container_name> /bin/sh-i,--interactive: Keep STDIN open even if not attached.-t,--tty: Allocate a pseudo-TTY.-it: Combining these allows you to interact with the container's shell.
A. Open a Shell in the Backend Container
# docker exec -it backend /bin/sh
podman exec -it backend /bin/shB. Open a Shell in the Frontend Container
# docker exec -it frontend /bin/sh
podman exec -it frontend /bin/shC. Run Prisma Commands Inside Backend If you need to manually run a Prisma command inside the container:
# docker exec -it backend npx prisma status
podman exec -it backend npx prisma statusD. Access PostgreSQL Directly
Connect to the database using psql inside the database container:
# docker exec -it postgres-db psql -U devuser -d main_db
podman exec -it postgres-db psql -U devuser -d main_dbE. Installing a New Package
If you've added a package to package.json on your host and need the container to install it immediately without restarting:
# docker exec -it backend npm install
podman exec -it backend npm installIf you want to run a command in a new container that stops and removes itself after the command finishes:
# docker run --rm -it alpine echo "Hello World"
podman run --rm -it alpine echo "Hello World"Click to view debugging guide for Backend and Frontend
Attaching a debugger to code running inside a Podman container allows you to set breakpoints, inspect variables, and step through code just like you would locally.
To debug the backend, we need to enable the Node.js inspector and expose the debugging port (9229) to your host machine.
Modify the backend service to expose the debug port and pass the inspector flag via environment variables:
backend:
...
ports:
- "3000:3000"
- "9229:9229" # Node.js Inspector port
environment:
- DATABASE_URL=postgresql://devuser:devpassword@postgres:5432/main_db?schema=public
- NODE_OPTIONS=--inspect=0.0.0.0:9229Create a .vscode/launch.json file in your project root to tell VS Code how to connect:
{
"version": "0.2.0",
"configurations": [
{
"name": "Podman: Attach to Backend",
"type": "node",
"request": "attach",
"address": "localhost",
"port": 9229,
"localRoot": "${workspaceFolder}/backend",
"remoteRoot": "/app",
"skipFiles": ["<node_internals>/**"]
}
]
}- Start your containers:
podman compose up. - Open the Run and Debug sidebar in VS Code (
Cmd+Shift+D). - Select Podman: Attach to Backend (updated label) and press the Play button (F5).
- Set a breakpoint in any
src/*.tsfile and trigger the API!
Frontend code runs in the browser, so debugging is primarily done there. However, you can still bridge the experience to VS Code.
Since the frontend is running in Podman and served via Nginx or Vite:
- Open http://localhost:3000 in Chrome.
- Press
F12orCmd+Option+Ito open DevTools. - Go to the Sources tab.
- Press
Cmd+Pand type the name of your component (e.g.,UserList.tsx). - Set breakpoints directly in the browser.
To debug React directly inside VS Code:
- Add this configuration to your
.vscode/launch.json:
{
"name": "Podman: Debug Frontend (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/src"
}- Press F5 in VS Code. It will open a new Chrome instance where your breakpoints will be synced with your source code.
Note: For the best development experience with hot-reloading and debugging, ensure you are using a development stage in Podman rather than the production Nginx build shown in the standard Dockerfile.
Click to view Podman PostgreSQL guide (Storage, Backups, and Restore)
Podman provides a secure, rootless alternative to Docker. Using Podman for your database ensures your development environment remains clean and consistent.
To ensure your data survives container restarts or deletions, we use Podman Volumes. Volumes are the preferred mechanism for persisting data generated by and used by Podman containers.
Create or update your compose.yaml (Podman uses the same format) to optimize for ARM64:
# version: '3.8'
services:
# PostgreSQL Optimized for ARM64 device
postgres:
# Using the official arm64v8/postgres image or standard postgres which is multi-arch
# THE "HEAVYWEIGHT": Based on Debian. Best for production. Handles different languages and sorting (locales) perfectly.
# image: postgres:17
# THE "LIGHTWEIGHT": Based on Alpine. Fast and tiny, but text sorting might behave differently (uses musl instead of glibc).
image: postgres:17-alpine
container_name: postgres-db
# restart: always
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: main_db
ports:
- "5432:5432"
volumes:
# Data persistence: maps local volume to container's data directory
- pgdata:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d main_db" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
driver: local- Start Podman: Ensure the Podman machine is running (if on macOS).
- Pull and Start:
# docker compose -f docker-compose.yml up -d # podman compose -f compose.yml up -d podman compose -f compose.dev.yml up postgres-db
-f compose.yml: Tells Podman to use this specific file instead of the default compose.yml.up: Builds, (re)creates, starts, and attaches to containers for a service.-d: Runs the containers in detached mode (in the background).
- Verify Growth:
# docker volume ls podman volume ls # docker volume inspect pgdata podman volume inspect pgdata
Your .env file for Prisma should look like this:
DATABASE_URL="postgresql://devuser:devpassword@localhost:5432/main_db?schema=public"# docker exec -it postgres-db psql -U devuser -d main_db
podman exec -it postgres-db psql -U devuser -d main_db- Host:
localhost - Port:
5432 - User:
devuser - Password:
devpassword - Database:
main_db
If you use the PostgreSQL extension for VS Code, follow these steps to connect:
- Click on the PostgreSQL icon in the Activity Bar on the left side of VS Code.
- In the PostgreSQL explorer, click the + (plus) button to add a new connection.
The extension will prompt you for details at the top of the VS Code window. Enter them as follows:
- Host Name:
localhost - PostgreSQL User:
devuser(or as configured in your.env) - PostgreSQL Password:
devpassword(or as configured in your.env) - Port:
5432 - Connection Method:
Standard(unless you specifically configured SSL) - Database Name:
main_db - Display Name:
main_db(or any name you prefer)
- Once saved, your database should appear in the PostgreSQL sidebar.
- You can expand it to see the main_db database, its schemas (likely
public), and your tables.
For Podman, performance is generally superior due to its rootless/daemonless architecture on many systems, but ensure your machine has sufficient resources allocated: podman machine inspect.
This section covers how to backup a PostgreSQL database and restore it to another environment (e.g., migrating from staging to local development).
If you don't have Homebrew yet, install it first via brew.sh. Then, run these commands to get the PostgreSQL utilities without the server:
# Install the library containing psql and pg_dump
brew install libpq
# Force link it so the commands are available in your terminal
echo 'export PATH="'$(brew --prefix)'/opt/libpq/bin:$PATH"' >> ~/.zshrc
source ~/.zshrcVerify: Type psql --version. If it returns a version number, you're ready.
Connect to your remote/source database and create the plain-text .sql file.
pg_dump -h <source_host> -U <source_user> -d <database> -f backup.sqlNote: When you run this, it will ask for your password. If you want to avoid typing it, you can run
export PGPASSWORD='your_password'right before the command, though be mindful of your terminal history security!
This is the most critical step. SQL dumps usually contain OWNER TO <old_user> commands. If your new environment uses a different username, the restore will fail.
Since you are on macOS, we use sed to swap the names.
# Replace 'old_owner' with the user from the original DB
# Replace 'new_owner' with your current environment's username
sed -i '' 's/OWNER TO old_owner/OWNER TO new_owner/g' backup.sql
# Optional: Also replace any specific GRANT permissions if they cause errors
sed -i '' 's/TO old_owner/TO new_owner/g' backup.sqlTip: Not sure who the old owner was? Open
backup.sqlin VS Code and search for "OWNER TO". You will see a line likeALTER TABLE public.mytable OWNER TO postgres;. In this case,postgresis your "old_owner."
Now, push that modified file into your target database.
Step 1: Create the target database (if it doesn't exist)
# Connect to 'postgres' (default db) to create your new one
psql -h <target_host> -U <target_user> -c "CREATE DATABASE <database>;"Note: When you run this, it will ask for your password. If you want to avoid typing it, you can run
export PGPASSWORD='your_password'right before the command, though be mindful of your terminal history security!
Step 2: Run the restore script
# -f: the file we just edited
# --set ON_ERROR_STOP=on: highly recommended so it doesn't ignore failures
psql -h <target_host> -U <target_user> -d <database> --set ON_ERROR_STOP=on -f backup.sql
# pg_restore -h <target_host> -U <target_user> -d <database> --clean --if-exists backup.dump
# Mention: update the production owner to the local db owner (see Section C)| Command | Description |
|---|---|
podman machine start |
Starts the Podman virtual machine (macOS). |
podman compose -f compose.dev.yml up |
Starts all services in the dev environment. |
podman compose -f compose.dev.yml up postgres-db |
Starts only the PostgreSQL database service. |
podman compose -f compose.dev.yml build backend --no-cache |
Rebuilds the backend service without using the cache. |
dropdb -h localhost -U devuser "main_db"; |
Drops (deletes) the local development database. |
createdb -h localhost -U devuser "main_db"; |
Creates a new local development database. |
pg_dump -h <prod_host> -U <prod_user> -d <prod_db> -f prod_backup.sql; |
Dumps the production database to a file. |
pg_dump -h localhost -U devuser -d main_db -f local_backup.sql; |
Dumps the local database to a file. |
psql -h localhost -U devuser -d main_db -f backup.sql; |
Restores a database from a backup file. |
| Command | Why use it? |
|---|---|
brew install libpq |
Gets tools without the heavy server background process. |
pg_dump ... -f file.sql |
Creates a human-readable text backup. |
sed -i '' 's/.../.../g' |
The macOS way to batch-edit the "Owner" inside the file. |
psql ... -f file.sql |
The "Player" that reads the script and rebuilds your DB. |
Click to view common issues and solutions
Here is the updated documentation. I have added the "Clean Slate" method as the primary recommendation for users migrating from Docker Desktop, as it is the most reliable way to clear configuration conflicts.
This guide covers the resolution for command not found errors and credential helper conflicts when transitioning from Docker Desktop to Podman.
Issue: After installing Podman Desktop, the podman-compose command is missing.
Cause: Podman Desktop bundles the Podman engine but not the legacy Python script. It uses the official docker-compose binary as an external provider.
- Remove Conflict: Clear any broken symlinks from previous Docker installs:
sudo rm -f /usr/local/bin/docker-compose- Enable in GUI: * Open Podman Desktop > Settings > Resources.
- Find Compose and click Setup.
- Correct Command: Use the modern syntax (space instead of hyphen):
podman compose upIssue: Errors like exec: "docker-credential-desktop": executable file not found.
Cause: Leftover settings in ~/.docker/config.json point to non-existent Docker Desktop tools.
This is the most effective way to fix "Ghost" Docker settings:
- Delete the old Docker config folder:
rm -rf ~/.docker- Re-initialize via Podman Desktop:
- Go back to Settings > Resources > Compose.
- Re-run the Setup/Installation.
- This forces Podman to create a fresh
config.jsonspecifically configured for macOS (osxkeychain).
- Verify:
cat ~/.docker/config.jsonIt should now correctly show "credsStore": "osxkeychain".
Error: Bind for 0.0.0.0:5432 failed: port is already allocated
Solution:
# Find process using the port
lsof -i :5432
# Kill the process
kill -9 <PID>
# Or change the port in compose.yaml (Podman/Docker)
<!-- # Or change the port in docker-compose.yml -->
ports:
- "5433:5432"Error: Cannot find module '@prisma/client'
Solution:
cd backend
npx prisma generateError: TypeScript errors like Property 'phoneNumber' does not exist on type 'User' (or any new field you just added) even though the field exists in schema.prisma.
Cause: The generated Prisma Client files are outdated and do not match your current schema.prisma definition. This happens when you modify the schema but haven't regenerated the client library yet.
Solution: Regenerate the Prisma Client to update the type definitions:
cd backend
npx prisma generateError: P3015: Could not find the migration file at prisma/migrations/.../migration.sql
Cause: A migration directory exists but is empty, or the migration.sql file is missing. This often happens if a migration creation process was interrupted or failed but the directory was left behind.
Background: When you run npx prisma migrate dev, Prisma compares your schema.prisma with the current database schema. If there are changes, it:
- Creates a new directory in
prisma/migrations/. - Generates a
migration.sqlfile inside that directory containing the SQL commands to apply the changes. - Applies the SQL to the database. If this process is interrupted (e.g., Ctrl+C), the directory might be created without the SQL file, leading to this error.
Solution:
- Delete the empty directory: Remove the faulty migration folder from
prisma/migrations. - Reset and Re-apply: Run
npx prisma migrate dev.- Note: If Prisma detects drift (schema out of sync), it may ask to reset the database.
CRITICAL RULE: Prisma will ask for permission before resetting the database (...Do you want to continue? All data will be lost.). If you see this prompt and your data is important, type N (No).
1. Production Safety (The "Never Dev" Rule)
- NEVER run
migrate devin production. It is designed for development and may attempt to reset the database if it detects conflicts. - ALWAYS use
migrate deployin production. This command applies pending migrations but will fail safely (exit code 1) instead of resetting if there is a conflict.
2. Development Workflow (Safe Mode)
- Use Create-Only: If you are unsure about a change, run
npx prisma migrate dev --create-only. This generates the SQL file without applying it, letting you inspect the SQL to ensure it handles data correctly (e.g., adding default values for new non-null columns). - Backup First: If you must fix a drift or apply a risky change, backup your container data first:
podman exec -t <container_name> pg_dump -U <username> -d <dbname> > backup.sql
3. Resolving Conflicts Without Reset
If migrate dev demands a reset due to "Drift detected":
- Say No to the reset.
- Introspect the actual database state:
npx prisma db pull. - Compare the updated
schema.prismawith your previous version to see what changed in the DB manually. - Decide whether to keep the manual changes (add them to your schema) or discard them (manually revert the DB changes).
Error: Can't reach database server
Solution:
- Ensure PostgreSQL container is healthy:
podman compose ps - Check DATABASE_URL environment variable
- Wait for database to be ready (health check)
Error: Access to fetch at 'http://localhost:3000' has been blocked by CORS policy
Solution:
- Ensure
cors()middleware is enabled in backend - Check that frontend is making requests to correct URL
- Rebuild containers:
podman compose up --build
Solution:
- Verify backend is running:
curl http://localhost:3000/health - Check VITE_API_URL in frontend/.env
- Inspect browser console for errors
- Verify nginx.conf proxy configuration
Problem: You ran npm install <package> on your Mac, but the application inside Podman fails with Error: Cannot find module '<package>'.
Cause: Depending on your compose.yaml setup, the node_modules folder inside the container may be isolated from your host's node_modules. Even if they are synced, packages installed on macOS might contain binaries that are incompatible with the Linux environment (Alpine) inside the container.
Solution:
- Direct Install: Run the install command inside the running container:
podman exec -it backend npm install - Restart with Build: The most reliable way is to restart the container while forcing a sync:
podman compose up --build backend
- Check Dockerignore: Ensure
node_modulesis listed in your.containerignore(or.dockerignore) file. This prevents your host's (macOS) modules from being copied into the container during the build process, forcing the container to build its own compatible versions.
Error: [plugin:vite:import-analysis] Failed to resolve import "@some/package" from "src/components/SomeComponent.tsx". Does the file exist?
Cause: The package exists in your host's package.json but isn't installed in the container's node_modules. This happens because:
- The
compose.yamluses an anonymous volume fornode_modules(e.g.,- /app/node_modules) to prevent overwriting with host binaries. - The container image was built before the dependency was added to
package.json. - The container is running with stale cached packages.
Solution:
Rebuild the container image to force a fresh npm install with the updated package.json:
# Rebuild and restart all services
podman compose -f compose.dev.yml up --build
# Or rebuild just the frontend service (rather run these commands separately)
podman compose -f compose.dev.yml down frontend && podman compose -f compose.dev.yml build frontend --no-cache && podman compose -f compose.dev.yml upTip: Always rebuild the container image after adding new dependencies to
package.json.
Problem: When running the app in an Android emulator, it cannot connect to http://localhost:5173 or http://localhost:3000.
Cause: The Android emulator runs on its own virtual network. localhost on the emulator refers to the emulator itself, not your host machine (Mac).
Solution:
-
Enable Host Access in Vite (
frontend/vite.config.ts): Configure the dev server to listen on all network interfaces, not just localhost inside the container.server: { // Listen on 0.0.0.0 host: true, // Required for Hot Module Replacement (HMR) to work in Podman on macOS watch: { usePolling: true } }
-
Restart the Frontend Container:
podman restart frontend
-
Bridge Ports with ADB: Use
adb reverseto map the emulator's ports to your computer's ports.# Map frontend port adb reverse tcp:5173 tcp:5173 # Map backend port adb reverse tcp:3000 tcp:3000
-
Verify: Open Chrome in the Android Emulator and browse to
http://localhost:5173. It should now load your app.
Solution:
- Open terminal
- Start the machine:
podman machine start - Verify with:
podman ps
Congratulations! You've built a full-stack application. Here are some enhancements to try:
- Authentication: Add JWT-based authentication
- Validation: Implement Zod or Yup for data validation
- Testing: Add Jest/Vitest unit tests
- Pagination: Implement pagination for large datasets
- Search: Add search functionality
- Deployment: Deploy to AWS, Azure, or DigitalOcean