Create a complete, production-ready MERN-stack application deployment setup using:
- React
- Node.js
- Express
- TypeScript
- DigitalOcean Ubuntu VPS/Droplet
- DigitalOcean Managed MongoDB
- Docker
- Docker Compose
- Yarn 1.x or npm
- A single application container serving both the React frontend and Express API
The final setup must be suitable for deployment to a DigitalOcean Ubuntu server using Podman Compose or Docker Compose.
Build the project using this architecture:
Internet
|
v
DigitalOcean Droplet
|
v
Docker/Podman container
|
+-- Express.js API
+-- Compiled React frontend
+-- Node.js production server
|
v
DigitalOcean Managed MongoDB
Do not run MongoDB inside Docker Compose.
MongoDB must be a separate DigitalOcean Managed Database service.
The React application and Express server should run together in one production container. Express should serve:
/api/*
for API routes and serve the compiled React application for all frontend routes.
Use:
React
TypeScript
Node.js
Express
MongoDB
Mongoose
Docker
Docker Compose
dotenv
Helmet
CORS
compression
JWT authentication
bcrypt
Use a clean project structure such as:
project-root/
├── client/
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── tsconfig.json
├── server/
│ ├── src/
│ │ ├── config/
│ │ ├── controllers/
│ │ ├── middleware/
│ │ ├── models/
│ │ ├── routes/
│ │ ├── services/
│ │ ├── types/
│ │ ├── app.ts
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── dist/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .env.example
├── package.json
├── tsconfig.json
└── README.md
You may improve this structure if a workspace or monorepo layout is cleaner.
The production application must run as a single Node.js process.
Do not use PM2 or pm2-runtime.
Do not use concurrently in production.
The production command should be equivalent to:
{
"scripts": {
"start": "NODE_ENV=production node dist/server/index.js"
}
}concurrently may remain as a development-only dependency and may be used for:
{
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\""
}
}Make it clear that development dependencies such as concurrently do not affect the production runtime when dependencies and Docker stages are configured correctly.
Create scripts that:
- Install server and client dependencies.
- Compile the React frontend.
- Compile the TypeScript Express server.
- Copy or output all production files into a predictable
distdirectory. - Start the server with:
node dist/server/index.jsThe Express server must serve the React build directory.
Implement SPA fallback routing so URLs such as:
/dashboard
/users
/reports
return the React application rather than a 404 error.
Do not allow the SPA fallback to intercept /api requests.
Create a multi-stage Dockerfile.
Use a supported Node.js LTS image.
The Dockerfile should:
- Install dependencies reproducibly.
- Use
yarn install --frozen-lockfilewhenyarn.lockexists. - Otherwise use
npm ciwhenpackage-lock.jsonexists. - Build the frontend and backend.
- Copy only required production files into the final image.
- Run as a non-root user.
- Expose port
3000. - Include a health check.
- Start the application using Node directly.
- Avoid PM2.
- Avoid running
npm run dev. - Avoid running
concurrentlyin production.
An acceptable final command is:
CMD ["node", "dist/server/index.js"]or:
CMD ["npm", "start"]provided npm start resolves to the direct production Node command.
Do not hide runtime errors using shell scripts that always return a successful exit code.
Create a docker-compose.yml containing only the application service.
Do not include a MongoDB container.
Use a configuration similar to:
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env
environment:
NODE_ENV: production
PORT: 3000
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 3
start_period: 20sExplain why this mapping is used:
127.0.0.1:3000:3000It binds the application only to the VPS loopback interface. This prevents direct public access to port 3000 while still allowing Nginx or another reverse proxy installed on the VPS to access the container.
Also show the alternative:
3000:3000and clearly explain that it exposes the container port publicly when the firewall allows it.
Ensure the Express server listens inside the container using:
app.listen(PORT, "0.0.0.0");Do not make Express listen on 127.0.0.1 inside the container, because that would prevent Docker or Podman port forwarding from reaching it.
Create a strongly typed environment configuration module.
Use explicit parsing and validation instead of reading process.env throughout the application.
Support required variables, optional variables, and variables with sensible fallback values.
Example requirements:
NODE_ENV=production
PORT=3000
MONGODB_URI=mongodb+srv://DATABASE_USER:DATABASE_PASSWORD@HOST/nfjpia?tls=true&authSource=admin&replicaSet=REPLICA_SET
JWT_SECRET=replace_with_a_secure_random_secret_at_least_32_bytes
MEMBER_QR_SIGNING_SECRET=replace_with_another_secure_random_secret
RESEND_API_KEY=
CLIENT_URL=https://example.com
TRUST_PROXY=1
LOG_LEVEL=infoCreate a .env.example with placeholders only.
Never put real secrets in:
- Dockerfile
docker-compose.yml- source code
- Git
- README examples
- frontend environment variables
Support fallback values for safe, non-secret configuration:
const port = parseInteger(process.env.PORT, 3000);
const nodeEnv = process.env.NODE_ENV ?? "development";
const logLevel = process.env.LOG_LEVEL ?? "info";
const trustProxy = process.env.TRUST_PROXY ?? "1";Do not silently create insecure fallback values for secrets.
The following variables must fail startup when missing in production:
MONGODB_URI
JWT_SECRET
MEMBER_QR_SIGNING_SECRET
Optional integrations such as Resend may remain blank when the related feature is disabled.
Example:
const resendApiKey = process.env.RESEND_API_KEY?.trim() || null;Do not write this in Compose unless the variable is truly mandatory:
RESEND_API_KEY: ${RESEND_API_KEY:?RESEND_API_KEY must be set}Use the ${VARIABLE:?error} syntax only for variables that are absolutely required for the application to start.
Implement boolean parsing correctly. Do not use:
Boolean(process.env.FEATURE_ENABLED)because the string "false" evaluates to true.
Use a helper such as:
function parseBoolean(
value: string | undefined,
fallback: boolean
): boolean {
if (value === undefined) {
return fallback;
}
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}Validate URLs, numbers, and minimum secret lengths.
Print useful startup errors without printing secret values.
The MongoDB service must be hosted using DigitalOcean Managed MongoDB.
Document the following setup steps:
- Create a DigitalOcean Managed MongoDB cluster.
- Select a region close to the DigitalOcean Droplet.
- Add the Droplet or its private network as a trusted source.
- Create a dedicated MongoDB database user for the application.
- Do not use the primary administrative account as the application account.
- Create a separate application database, such as:
nfjpia
- Use the application database name in the connection URI.
- Keep
authSource=adminwhen the DigitalOcean-managed user authenticates against the administrative authentication database. - Store the connection string only in the server
.env. - Test the database connection from the VPS.
Explain the important difference between:
/nfjpia
and:
/admin
The path portion of the URI selects the application database used by Mongoose.
For example:
mongodb+srv://user:password@cluster-host/nfjpia?authSource=admin
means:
- Authenticate the user against the
adminauthentication database. - Use
nfjpiaas the application database.
Do not use /admin as the application database merely because authSource=admin exists.
Use the exact connection string supplied by DigitalOcean as the base.
Do not manually remove required options from the DigitalOcean connection string.
Preserve parameters such as:
tls=true
authSource=admin
replicaSet=...
when provided.
Make sure usernames and passwords are URL encoded.
For example, passwords containing these characters require encoding:
@
:
/
?
#
%
Use:
encodeURIComponent(password)when building a connection string programmatically.
Prefer storing the complete connection URI in one environment variable rather than assembling it at runtime.
Implement startup connection logic with reasonable timeouts:
await mongoose.connect(config.mongodbUri, {
serverSelectionTimeoutMS: 10000,
connectTimeoutMS: 10000
});Add clear logging for:
- DNS resolution failures
- connection timeouts
- authentication failures
- untrusted-source failures
- TLS failures
- missing database names
Do not log the complete MongoDB URI.
Redact credentials when showing connection diagnostics.
Because managed MongoDB often uses an SRV URI such as:
mongodb+srv://...
document how to diagnose DNS failures from the VPS and from inside the container.
Include commands such as:
getent hosts example.mongodb.ondigitalocean.comnslookup -type=SRV _mongodb._tcp.example.mongodb.ondigitalocean.comdig SRV _mongodb._tcp.example.mongodb.ondigitalocean.comTest from inside the running application container:
docker compose exec app getent hosts example.mongodb.ondigitalocean.comor:
podman-compose exec app getent hosts example.mongodb.ondigitalocean.comDocument that an SRV DNS timeout may be caused by:
- Broken VPS DNS configuration
- A restrictive firewall
- Container DNS issues
- An outdated container runtime
- Incorrect DNS resolver settings
- An invalid MongoDB hostname
- A transient provider DNS problem
Support optional Compose DNS overrides for troubleshooting:
dns:
- 1.1.1.1
- 8.8.8.8Do not add custom DNS servers by default unless necessary.
Create example Mongoose models for:
User
Session or RefreshToken
AuditLog
The User model should include:
email
username
passwordHash
roles
isActive
createdAt
updatedAt
Use indexes for fields such as:
email
username
createdAt
Create an initialization or seed command that can:
- Confirm connectivity.
- Create indexes.
- Optionally create the first administrator.
- Refuse to overwrite an existing administrator.
- Read the administrator password from an environment variable or interactive input.
- Never commit default administrator credentials.
MongoDB creates the application database and collections when the application first writes data. Explain this behavior, but still treat nfjpia as the intentional application database configured in the URI.
Implement a basic secure authentication system.
Requirements:
- Password hashing using bcrypt.
- JWT access tokens.
- Optional refresh token support.
- Secure HTTP-only cookie support.
- Configurable cookie domain.
- Configurable secure-cookie behavior.
- SameSite configuration.
- Login rate limiting.
- Basic request validation.
- Helmet middleware.
- Payload-size limits.
- CORS allowlist.
- Audit logs for login success and failure.
- Generic login error messages that do not reveal whether an account exists.
Use a JWT secret of at least 32 random bytes.
Do not use placeholder secrets such as:
your_jwt_secret
in production.
Add instructions for generating secrets:
openssl rand -base64 48Do not disable deprecation warnings globally merely to make logs cleaner.
Avoid using:
NODE_OPTIONS=--no-deprecationas the default solution.
Instead, identify and update the package producing the warning when practical. A warning such as Node's url.parse() deprecation is normally not a deployment failure by itself.
Create:
GET /health
GET /ready
/health should indicate that the Node process is running.
Example response:
{
"status": "ok",
"uptime": 123.45
}/ready should check whether MongoDB is connected.
Do not expose sensitive system details.
Use correct HTTP status codes:
200 when ready
503 when the database is unavailable
Handle:
SIGTERM
SIGINT
On shutdown:
- Stop accepting new HTTP requests.
- Close the HTTP server.
- Close the Mongoose connection.
- Exit cleanly.
- Force exit only after a reasonable timeout.
This is necessary for reliable Docker and Podman restarts.
Provide an example Nginx configuration for the Ubuntu VPS.
Nginx should proxy to:
http://127.0.0.1:3000
Include:
- Forwarded headers
- WebSocket support
- Reasonable timeout configuration
- Upload-size configuration
- HTTP-to-HTTPS redirect
- Certbot-compatible HTTPS instructions
Express should use:
app.set("trust proxy", 1);when running behind Nginx.
Do not expose port 3000 publicly when Nginx is being used.
Create a complete deployment guide for a new DigitalOcean Ubuntu Droplet.
Include commands to:
- Create a non-root deployment user.
- Add SSH keys.
- Disable password-based SSH login after verifying key access.
- Update system packages.
- Configure UFW.
- Allow SSH, HTTP, and HTTPS.
- Install Git.
- Install Docker Engine and Docker Compose plugin, or Podman and Podman Compose.
- Clone the private repository securely.
- Create the production
.env. - Build the image.
- Start the application.
- Inspect logs.
- Restart the application.
- Verify MongoDB connectivity.
- Install and configure Nginx.
- Install HTTPS certificates.
Include Docker commands:
docker compose build
docker compose up -d
docker compose ps
docker compose logs -f app
docker compose restart app
docker compose downInclude Podman equivalents:
podman-compose build
podman-compose up -d
podman-compose ps
podman-compose logs -f app
podman-compose restart app
podman-compose downAccount for older installations where the command may be:
docker-composeinstead of:
docker composeThe Compose file must work with modern Docker Compose and Podman Compose as closely as possible.
Avoid Docker-specific features unless clearly documented.
When a Podman Compose recreation log contains a harmless message such as:
no container with ID or name found
followed by successful container creation, explain that the old container may already have been removed and that the important result is whether the replacement container starts successfully.
Use these commands for diagnosis:
podman ps -a
podman logs app-container-name
podman inspect app-container-name
podman port app-container-nameDo not assume the application is healthy merely because the container was created.
Use a current Node.js LTS version supported by the project's dependencies.
Explain that using a newer Node.js runtime can expose:
- Deprecation warnings
- Old package incompatibilities
- Native module build failures
- Lockfile differences
- Stricter URL or TLS behavior
A modern system is not necessarily “too new,” but older project dependencies may need upgrading.
Add an engines entry:
{
"engines": {
"node": ">=20 <25"
}
}Choose the exact supported range based on the generated dependency versions.
Create .nvmrc or .node-version with the selected Node.js major version.
Do not suppress warnings before determining whether they are harmless or actionable.
Include at least:
node_modules
client/node_modules
server/node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
npm-debug.log*
yarn-error.log*
coverage
.vscode
.idea
Make sure .env is not included in the image build context.
Create a secure .gitignore.
Include:
.env
.env.*
!.env.example
node_modules
dist
coverage
*.log
Explain that if a real API key or database credential has ever been committed, pasted into a public issue, exposed in logs, or shared in an insecure location, it should be rotated.
Removing the value from the latest Git commit does not necessarily remove it from Git history.
Never print environment secrets during application startup.
Document how to configure MongoDB trusted sources.
The managed MongoDB cluster should permit connections from the Droplet using the appropriate trusted-source mechanism.
Prefer:
- The specific DigitalOcean Droplet
- A DigitalOcean VPC
- A specific static IP address
Avoid allowing unrestricted public access such as:
0.0.0.0/0
except temporarily during controlled troubleshooting.
Explain that the application may fail even with the correct username and password when the Droplet is not included in the database trusted sources.
Provide a safe development example:
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/nfjpia
JWT_SECRET=development_only_change_me
MEMBER_QR_SIGNING_SECRET=development_only_change_me_too
CLIENT_URL=http://localhost:5173
TRUST_PROXY=0
LOG_LEVEL=debugProvide a safe production template:
NODE_ENV=production
PORT=3000
MONGODB_URI=mongodb+srv://APP_USER:URL_ENCODED_PASSWORD@DIGITALOCEAN_HOST/nfjpia?tls=true&authSource=admin&replicaSet=REPLACE_ME
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET
MEMBER_QR_SIGNING_SECRET=REPLACE_WITH_DIFFERENT_RANDOM_SECRET
RESEND_API_KEY=
CLIENT_URL=https://app.example.com
TRUST_PROXY=1
LOG_LEVEL=infoDo not include actual credentials.
Create centralized Express error handling.
Include:
- Validation errors
- Authentication errors
- Authorization errors
- MongoDB duplicate-key errors
- Mongoose validation errors
- Database connectivity failures
- Generic internal server errors
Return safe production responses.
Log enough diagnostic information on the server without returning stack traces to clients.
Implement structured logging.
Log:
timestamp
level
request ID
HTTP method
route
status code
duration
user ID when available
Never log:
passwords
JWTs
cookies
authorization headers
MongoDB credentials
API keys
full request bodies containing personal data
Add a request ID middleware.
Generate all required code and configuration files, including:
Dockerfile
docker-compose.yml
.dockerignore
.gitignore
.env.example
package.json files
TypeScript configuration
Express server
React application
MongoDB configuration
Mongoose models
authentication example
health endpoints
error middleware
logging middleware
Nginx configuration
deployment instructions
troubleshooting guide
README.md
The README must contain exact commands for both Docker Compose and Podman Compose.
Include a troubleshooting section for:
Commands:
docker compose ps
docker compose logs appor:
podman-compose ps
podman-compose logs appCheck:
- Invalid
CMD - Missing compiled files
- Missing required variables
- Wrong Node version
- Database startup failure
- Port already in use
Check:
- Username
- URL-encoded password
- Database user permissions
authSource=admin- Exact DigitalOcean connection URI
- Correct application database path
Check:
- Trusted sources
- VPS outbound network access
- DNS resolution
- TLS options
- Correct hostname
- Container DNS
Check:
dig SRV _mongodb._tcp.MONGODB_HOSTand run the same test inside the container.
Confirm Express listens on:
0.0.0.0:3000
not:
127.0.0.1:3000
inside the container.
Check:
curl http://127.0.0.1:3000/healthCheck container status, port mapping, Nginx upstream, and firewall rules.
Explain that Compose may need a recreation:
docker compose up -d --force-recreateor:
podman-compose up -d --force-recreateA simple restart may not apply all changed Compose environment values.
Confirm the Express SPA fallback is configured after API routes.
Before declaring the setup complete, verify:
[ ] TypeScript builds without errors
[ ] React production build completes
[ ] Docker image builds
[ ] Container runs as a non-root user
[ ] Container listens on 0.0.0.0:3000
[ ] VPS exposes only SSH, HTTP, and HTTPS
[ ] Port 3000 is bound to 127.0.0.1 on the host
[ ] /health returns 200
[ ] /ready returns 200 after MongoDB connects
[ ] Managed MongoDB uses a dedicated application user
[ ] The URI selects the nfjpia application database
[ ] authSource remains admin when required
[ ] The Droplet is in MongoDB trusted sources
[ ] Password is URL encoded
[ ] Secrets are absent from Git
[ ] PM2 is not required
[ ] concurrently is not used in production
[ ] Graceful shutdown works
[ ] Nginx proxies successfully
[ ] HTTPS works
[ ] Logs do not expose secrets
First provide the complete directory tree.
Then provide every file in a separate fenced code block with its full path as the heading.
Do not omit implementation details with statements such as:
Add your logic here.
Configure this as needed.
Implementation left as an exercise.
Generate functional starter code.
After the files, provide:
- Local development instructions.
- Docker build instructions.
- DigitalOcean Managed MongoDB setup instructions.
- Ubuntu VPS setup instructions.
- Nginx and HTTPS instructions.
- Docker Compose deployment instructions.
- Podman Compose deployment instructions.
- MongoDB and DNS troubleshooting instructions.
- Security checklist.
- Production verification commands.
Favor straightforward, maintainable code over unnecessary abstractions.
Lesson Learned: Container Path Resolution (Absolute vs. Relative)
Issue:
Executing
node /dist/server/seed-admin.jsfailed because Node couldn't locate the file, whereasnode /app/dist/server/seed-admin.jssucceeded.Root Cause:
A leading slash (
/) specifies an absolute path starting from the root of the filesystem, not the current working directory./dist/server/...tells Linux to look at the filesystem root (/dist), which doesn't exist inside the container.WORKDIR /app, built assets live in/app/dist/....Quick Summary of Path Types in Containers:
/app/dist/server/seed-admin.js/appdirectory regardless ofWORKDIR.dist/server/seed-admin.js/app)./dist/server/seed-admin.jsdistfolder directly in root (/).Verification Rule of Thumb:
When debugging container execution errors, always verify the active directory and target structure: