Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save myriaglot/a72dfb473560f88de6ae89d93f5f438f to your computer and use it in GitHub Desktop.

Select an option

Save myriaglot/a72dfb473560f88de6ae89d93f5f438f to your computer and use it in GitHub Desktop.
Digitalocean mern stack setup prompt

Build a Production-Ready DigitalOcean MERN Stack with Managed MongoDB

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.

1. Target Architecture

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.

2. Required Technology Stack

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.

3. Production Runtime

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.

4. Build Process

Create scripts that:

  1. Install server and client dependencies.
  2. Compile the React frontend.
  3. Compile the TypeScript Express server.
  4. Copy or output all production files into a predictable dist directory.
  5. Start the server with:
node dist/server/index.js

The 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.

5. Dockerfile

Create a multi-stage Dockerfile.

Use a supported Node.js LTS image.

The Dockerfile should:

  • Install dependencies reproducibly.
  • Use yarn install --frozen-lockfile when yarn.lock exists.
  • Otherwise use npm ci when package-lock.json exists.
  • 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 concurrently in 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.

6. Docker Compose

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: 20s

Explain why this mapping is used:

127.0.0.1:3000:3000

It 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:3000

and 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.

7. Environment Variable Design

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=info

Create 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.

8. MongoDB Managed Database Requirements

The MongoDB service must be hosted using DigitalOcean Managed MongoDB.

Document the following setup steps:

  1. Create a DigitalOcean Managed MongoDB cluster.
  2. Select a region close to the DigitalOcean Droplet.
  3. Add the Droplet or its private network as a trusted source.
  4. Create a dedicated MongoDB database user for the application.
  5. Do not use the primary administrative account as the application account.
  6. Create a separate application database, such as:
nfjpia
  1. Use the application database name in the connection URI.
  2. Keep authSource=admin when the DigitalOcean-managed user authenticates against the administrative authentication database.
  3. Store the connection string only in the server .env.
  4. 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 admin authentication database.
  • Use nfjpia as the application database.

Do not use /admin as the application database merely because authSource=admin exists.

9. MongoDB URI Handling

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.

10. DNS and SRV Troubleshooting

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.com
nslookup -type=SRV _mongodb._tcp.example.mongodb.ondigitalocean.com
dig SRV _mongodb._tcp.example.mongodb.ondigitalocean.com

Test from inside the running application container:

docker compose exec app getent hosts example.mongodb.ondigitalocean.com

or:

podman-compose exec app getent hosts example.mongodb.ondigitalocean.com

Document 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.8

Do not add custom DNS servers by default unless necessary.

11. Mongoose Models and Database Initialization

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.

12. Authentication and Security

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 48

Do not disable deprecation warnings globally merely to make logs cleaner.

Avoid using:

NODE_OPTIONS=--no-deprecation

as 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.

13. Health and Readiness Endpoints

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

14. Graceful Shutdown

Handle:

SIGTERM
SIGINT

On shutdown:

  1. Stop accepting new HTTP requests.
  2. Close the HTTP server.
  3. Close the Mongoose connection.
  4. Exit cleanly.
  5. Force exit only after a reasonable timeout.

This is necessary for reliable Docker and Podman restarts.

15. Reverse Proxy

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.

16. Ubuntu VPS Setup

Create a complete deployment guide for a new DigitalOcean Ubuntu Droplet.

Include commands to:

  1. Create a non-root deployment user.
  2. Add SSH keys.
  3. Disable password-based SSH login after verifying key access.
  4. Update system packages.
  5. Configure UFW.
  6. Allow SSH, HTTP, and HTTPS.
  7. Install Git.
  8. Install Docker Engine and Docker Compose plugin, or Podman and Podman Compose.
  9. Clone the private repository securely.
  10. Create the production .env.
  11. Build the image.
  12. Start the application.
  13. Inspect logs.
  14. Restart the application.
  15. Verify MongoDB connectivity.
  16. Install and configure Nginx.
  17. 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 down

Include Podman equivalents:

podman-compose build
podman-compose up -d
podman-compose ps
podman-compose logs -f app
podman-compose restart app
podman-compose down

Account for older installations where the command may be:

docker-compose

instead of:

docker compose

17. Podman Compatibility

The 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-name

Do not assume the application is healthy merely because the container was created.

18. Dependency and Node.js Compatibility

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.

19. .dockerignore

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.

20. Git Security

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.

21. DigitalOcean Trusted Sources

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.

22. Environment File Examples

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=debug

Provide 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=info

Do not include actual credentials.

23. Error Handling

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.

24. Logging

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.

25. Deliverables

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.

26. Troubleshooting Guide

Include a troubleshooting section for:

Application container immediately exits

Commands:

docker compose ps
docker compose logs app

or:

podman-compose ps
podman-compose logs app

Check:

  • Invalid CMD
  • Missing compiled files
  • Missing required variables
  • Wrong Node version
  • Database startup failure
  • Port already in use

MongoDB authentication failure

Check:

  • Username
  • URL-encoded password
  • Database user permissions
  • authSource=admin
  • Exact DigitalOcean connection URI
  • Correct application database path

MongoDB server selection timeout

Check:

  • Trusted sources
  • VPS outbound network access
  • DNS resolution
  • TLS options
  • Correct hostname
  • Container DNS

SRV lookup timeout

Check:

dig SRV _mongodb._tcp.MONGODB_HOST

and run the same test inside the container.

Application works inside container but not from the VPS

Confirm Express listens on:

0.0.0.0:3000

not:

127.0.0.1:3000

inside the container.

Nginx returns 502

Check:

curl http://127.0.0.1:3000/health

Check container status, port mapping, Nginx upstream, and firewall rules.

Environment variables are not updating

Explain that Compose may need a recreation:

docker compose up -d --force-recreate

or:

podman-compose up -d --force-recreate

A simple restart may not apply all changed Compose environment values.

React routes return 404

Confirm the Express SPA fallback is configured after API routes.

27. Validation Checklist

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

28. Output Format

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:

  1. Local development instructions.
  2. Docker build instructions.
  3. DigitalOcean Managed MongoDB setup instructions.
  4. Ubuntu VPS setup instructions.
  5. Nginx and HTTPS instructions.
  6. Docker Compose deployment instructions.
  7. Podman Compose deployment instructions.
  8. MongoDB and DNS troubleshooting instructions.
  9. Security checklist.
  10. Production verification commands.

Favor straightforward, maintainable code over unnecessary abstractions.

@myriaglot

Copy link
Copy Markdown
Author

Lesson Learned: Container Path Resolution (Absolute vs. Relative)

Issue:
Executing node /dist/server/seed-admin.js failed because Node couldn't locate the file, whereas node /app/dist/server/seed-admin.js succeeded.

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.
  • Because the Dockerfile sets WORKDIR /app, built assets live in /app/dist/....

Quick Summary of Path Types in Containers:

Path Syntax Type Behavior in Container Result
/app/dist/server/seed-admin.js Absolute Targets explicit /app directory regardless of WORKDIR. Works
dist/server/seed-admin.js Relative Resolves relative to current working directory (/app). Works
/dist/server/seed-admin.js Absolute Looks for a dist folder directly in root (/). Fails

Verification Rule of Thumb:
When debugging container execution errors, always verify the active directory and target structure:

podman exec -it <container_name> pwd
podman exec -it <container_name> ls -la /app/dist/server

@myriaglot

Copy link
Copy Markdown
Author

Summary of Podman Deployment Lessons Learned

The deployment took longer than expected because several small configuration issues and misunderstandings appeared to be one large failure. The main difficulty was not the application itself, but having limited experience interpreting container errors and how to diagnose cases where the container stopped without immediately showing a clear cause.

The main lesson was to keep the deployment simple and test each layer separately: the image, environment variables, network access, database connection, application process, and port mapping.

Container bridge networking normally works on both desktop and server environments. However, a server may use different DNS resolvers, firewall rules, Podman or Compose versions, and network configurations. In this case, an older Podman Compose setup and its custom network behaved differently from running the image directly with Podman.

The best lesson was not to assume that a successful build means a working application, and not to add more tools or configuration before identifying the exact layer that is failing.

Another lesson was the importance of basic networking tools such as dig and /etc/resolv.conf. A DNS lookup may work on the VPS but still fail inside a container because the container can use different DNS settings or pass requests through a container network.

This means DNS should be tested both on the host and from the same container configuration used by the application. When necessary, DNS servers can be specified explicitly in the container configuration.

The key lesson is that successful DNS resolution on the server does not automatically prove that the application container can resolve the same external service.


Lesson #1: Image build success is not app success

This:

Successfully tagged localhost/project_app:latest

only means the image was built.

The container can still fail because of missing environment variables, database timeouts, bad paths, or startup errors.

Always check the container state and logs separately.


Lesson #2: YAML environment syntax is easy to get wrong

Use one format consistently.

Mapping style

environment:
  PORT: "3000"
  NODE_ENV: production
  MONGODB_URI: ${MONGODB_URI:?"MONGODB_URI must be set"}

List style

environment:
  - PORT=3000
  - NODE_ENV=production
  - MONGODB_URI=${MONGODB_URI:?"MONGODB_URI must be set"}

Do not mix them:

environment:
  - PORT:3000

For mapping style, always include a space after :.


Lesson #3: ${VAR:?message} is not a fallback

This:

MONGODB_URI: ${MONGODB_URI:?"MONGODB_URI must be set"}

means the variable is required.

This provides a fallback:

MONGODB_URI: ${MONGODB_URI:-mongodb://localhost/database}

Simple rule:

:? = stop if missing
:- = use a default

Lesson #4: .env values must be passed into the container

A value existing in .env does not guarantee the application receives it.

The Compose or Podman configuration must pass it through:

environment:
  RESEND_API_KEY: ${RESEND_API_KEY:?"RESEND_API_KEY must be set"}

Variable names must match exactly.


Lesson #5: Direct podman run is an important debugging tool

The image worked when run directly:

podman run --rm -it \
  --env-file .env \
  -p 127.0.0.1:3000:3000 \
  localhost/project_app:latest

But it failed through an older podman-compose setup.

This proved that the image, application, environment file, and database connection were valid. The remaining issue was Compose-specific networking or configuration.

When Compose fails, test the image directly before changing application code.


Lesson #6: A custom network may be unnecessary

A single application connecting to an external managed database usually does not need a manually declared bridge network.

Removing the custom network simplified the setup and avoided DNS behavior seen with an older Podman Compose version.

Use custom networks when multiple local services need to communicate by service name, not automatically for every container.


Lesson #7: /app/dist and /dist are different paths

The Dockerfile used:

WORKDIR /app

Therefore the compiled application existed under:

/app/dist

This worked:

node /app/dist/server/seed-admin.js

This did not:

node /dist/server/seed-admin.js

A leading / starts from the container filesystem root.

These are valid when the current directory is /app:

node dist/server/index.js
node /app/dist/server/index.js

Lesson #8: Production should run compiled files

The production image contained compiled JavaScript under dist, not TypeScript source files under src.

Development command:

tsx src/server/seed-admin.ts

Production command:

node dist/server/seed-admin.js

Production scripts should reference files that actually exist in the final image.


Lesson #9: PM2 was unnecessary

The deployment had one Node.js process inside one container.

Using PM2 would create:

Podman → PM2 → Node.js

The simpler setup is:

Podman → Node.js

Podman already manages the container lifecycle and supports restart policies:

--restart unless-stopped

PM2 is useful when PM2 cluster mode, multiple workers, or PM2-specific monitoring is intentionally required.

For one unclustered Node.js process, PM2 usually adds another layer without solving a real problem.


Final takeaway

The working setup was intentionally simple:

VPS
→ Podman
→ one Node.js process
→ compiled application
→ managed external database

The main lessons were:

  • validate YAML carefully;
  • understand Compose variable interpolation;
  • pass every required environment variable explicitly;
  • test images directly when Compose behaves unexpectedly;
  • use the correct container filesystem path;
  • run compiled production files;
  • and, avoid PM2 when the container runtime already manages one Node.js process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment