← Back to DevBytes

Mongoose from Beginner to Expert: A Learning Path

What is Mongoose?

Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a schema-based solution to model your application data, managing relationships between data, validating data, and translating between objects in code and their representation in MongoDB. In essence, Mongoose gives you a structured way to interact with MongoDB, which is inherently schema-less, by allowing you to define schemas at the application level.

Why Mongoose Matters

MongoDB is flexible—you can insert documents with completely different shapes into the same collection. While this flexibility is powerful, real-world applications need structure, validation, and consistency. Mongoose bridges that gap by offering:

Getting Started: Installation and Connection

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

First, install Mongoose in your Node.js project. Make sure you have MongoDB running locally or have a remote connection string ready (such as MongoDB Atlas).

npm install mongoose

Now, let's establish a connection. The best practice is to handle the connection asynchronously and set up event listeners to monitor the connection state.

// app.js or server.js
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    await mongoose.connect('mongodb://127.0.0.1:27017/my_database', {
      // Mongoose 6+ has these options as defaults; explicit inclusion is optional
    });
    console.log('MongoDB connected successfully');
  } catch (error) {
    console.error('MongoDB connection error:', error.message);
    // Exit process with failure in production
    process.exit(1);
  }
};

// Monitor connection events
mongoose.connection.on('connected', () => {
  console.log('Mongoose connected to the database');
});

mongoose.connection.on('error', (err) => {
  console.log('Mongoose connection error:', err);
});

mongoose.connection.on('disconnected', () => {
  console.log('Mongoose disconnected');
});

// Graceful shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('Mongoose connection closed due to app termination');
  process.exit(0);
});

connectDB();

For production applications connecting to MongoDB Atlas, use the connection string from your Atlas dashboard, ensuring you handle credentials securely via environment variables.

const MONGO_URI = process.env.MONGO_URI || 'mongodb+srv://user:password@cluster.mongodb.net/myDB';
await mongoose.connect(MONGO_URI);

Defining Schemas and Models

A Schema is the blueprint for your documents. It defines fields, their types, default values, validators, and more. A Model is the compiled constructor created from the schema—it's the actual interface you use to interact with the collection.

Creating Your First Schema

const mongoose = require('mongoose');
const { Schema } = mongoose;

const userSchema = new Schema({
  name: {
    type: String,
    required: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    validate: {
      validator: function(v) {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
      },
      message: props => `${props.value} is not a valid email address!`
    }
  },
  age: {
    type: Number,
    min: 0,
    max: 120,
    default: null
  },
  createdAt: {
    type: Date,
    default: Date.now,
    immutable: true // cannot be changed after creation
  },
  bio: {
    type: String,
    maxlength: 500
  }
});

// Compile schema into a Model
const User = mongoose.model('User', userSchema);

module.exports = User;

Schema Types at a Glance

Mongoose supports the following SchemaTypes:

CRUD Operations: The Core of Mongoose

Once your model is defined, you can perform all standard database operations. Mongoose provides a fluent API with both callback-style and Promise-based methods.

Creating Documents

There are several ways to create and save documents:

// Method 1: Create a document instance, then save
const newUser = new User({
  name: 'Alice Johnson',
  email: 'alice@example.com',
  age: 28,
  bio: 'Software engineer and open source contributor'
});

try {
  const savedUser = await newUser.save();
  console.log('User saved:', savedUser);
} catch (error) {
  console.error('Save error:', error.message);
}

// Method 2: Use Model.create() - creates and saves in one step
const user = await User.create({
  name: 'Bob Smith',
  email: 'bob@example.com',
  age: 32
});
console.log('User created:', user);

// Method 3: Insert multiple documents at once
const users = await User.insertMany([
  { name: 'Charlie', email: 'charlie@example.com', age: 25 },
  { name: 'Diana', email: 'diana@example.com', age: 29 },
  { name: 'Eve', email: 'eve@example.com', age: 35 }
]);
console.log(`${users.length} users inserted`);

Reading Documents (Queries)

// Find all users
const allUsers = await User.find({});

// Find users with criteria and chain query modifiers
const youngUsers = await User.find({ age: { $lt: 30 } })
  .select('name email age')
  .sort({ age: 1 })          // ascending by age
  .limit(10)
  .lean();                    // returns plain JS objects, not Mongoose docs

// Find a single document by some condition
const userByEmail = await User.findOne({ email: 'alice@example.com' });

// Find by ID (convenience method)
const userById = await User.findById('64a1b2c3d4e5f6g7h8i9j0k1');

// Complex query with logical operators
const filteredUsers = await User.find({
  $or: [
    { age: { $gte: 25, $lte: 40 } },
    { name: { $in: ['Alice Johnson', 'Bob Smith'] } }
  ]
});

// Using cursor for large datasets
const cursor = User.find({ age: { $gt: 20 } }).cursor();
for await (const doc of cursor) {
  console.log(doc.name);
}

Updating Documents

// Update a document and return the updated version
const updatedUser = await User.findByIdAndUpdate(
  '64a1b2c3d4e5f6g7h8i9j0k1',
  { age: 29, bio: 'Updated bio text here' },
  { 
    new: true,        // return the modified document
    runValidators: true // run schema validators on update
  }
);

// Update multiple documents matching a condition
const updateResult = await User.updateMany(
  { age: { $lt: 25 } },
  { $set: { bio: 'Young professional' } }
);
console.log(`Modified ${updateResult.modifiedCount} documents`);

// Update one document without returning it
await User.updateOne(
  { email: 'bob@example.com' },
  { $inc: { age: 1 } } // increment age by 1
);

// Find a document, modify it, and save (useful for complex logic)
const user = await User.findOne({ name: 'Alice Johnson' });
if (user) {
  user.bio = 'Updated via save() method';
  user.age += 1;
  await user.save();
}

Deleting Documents

// Delete one document
await User.deleteOne({ email: 'eve@example.com' });

// Delete multiple documents
const deleteResult = await User.deleteMany({ age: { $lt: 18 } });
console.log(`Deleted ${deleteResult.deletedCount} documents`);

// Find and delete, returning the deleted document
const deletedUser = await User.findByIdAndDelete('64a1b2c3d4e5f6g7h8i9j0k1');
if (deletedUser) {
  console.log('Deleted user:', deletedUser.name);
}

// Another find-and-delete variant
const removedUser = await User.findOneAndDelete({ email: 'charlie@example.com' });

Schema Validation Deep Dive

Mongoose provides robust validation mechanisms. Built-in validators cover common needs, but you can always define custom validation logic.

Built-in Validators

const productSchema = new Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],
    minlength: [3, 'Name must be at least 3 characters'],
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  price: {
    type: Number,
    required: true,
    min: [0.01, 'Price must be greater than 0'],
    max: [99999, 'Price seems too high']
  },
  rating: {
    type: Number,
    enum: {
      values: [1, 2, 3, 4, 5],
      message: '{VALUE} is not a valid rating; must be 1-5'
    }
  },
  tags: {
    type: [String],
    validate: {
      validator: function(arr) {
        return arr.length <= 10;
      },
      message: 'A product can have at most 10 tags'
    }
  }
});

Custom Validators

const orderSchema = new Schema({
  items: {
    type: [{
      productId: String,
      quantity: Number,
      priceAtTime: Number
    }],
    required: true,
    validate: {
      validator: function(items) {
        return items.length > 0;
      },
      message: 'An order must have at least one item'
    }
  },
  totalAmount: {
    type: Number,
    required: true,
    validate: {
      validator: function(value) {
        // Custom async validator
        return new Promise((resolve) => {
          // Simulate async check
          setTimeout(() => {
            const calculated = this.items.reduce(
              (sum, item) => sum + item.quantity * item.priceAtTime, 0
            );
            resolve(Math.abs(value - calculated) < 0.01);
          }, 100);
        });
      },
      message: 'Total amount must match sum of item prices'
    }
  },
  couponCode: {
    type: String,
    validate: {
      validator: async function(code) {
        if (!code) return true; // optional field
        // Check against external service or database
        const couponExists = await Coupon.findOne({ code: code });
        return !!couponExists;
      },
      message: 'Invalid or expired coupon code'
    }
  }
});

Validation Timing

By default, validators run on save() but not on updateOne(), findByIdAndUpdate(), etc. To enforce validation on updates, pass { runValidators: true } in the options object. Also note that unique: true is not a validator—it creates a unique index, and the database enforces uniqueness at save time.

Middleware (Hooks)

Middleware functions, also called hooks, let you execute code at specific stages of the document lifecycle. They come in two flavors: pre (before the operation) and post (after the operation).

Pre Middleware

Pre hooks run before the target operation. They can modify the document or abort the operation.

// Hash a password before saving
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;

userSchema.pre('save', async function(next) {
  // 'this' refers to the document being saved
  if (this.isModified('password') || this.isNew) {
    try {
      const salt = await bcrypt.genSalt(SALT_ROUNDS);
      this.password = await bcrypt.hash(this.password, salt);
    } catch (error) {
      return next(error);
    }
  }
  next();
});

// Pre-find middleware to add a query filter automatically
userSchema.pre('find', function() {
  // 'this' refers to the Query object
  this.where({ isActive: true }); // only find active users
});

// Pre middleware for delete operations
userSchema.pre('deleteOne', { document: true, query: false }, function(next) {
  // When called on a document instance
  console.log(`User ${this.name} is about to be deleted`);
  next();
});

Post Middleware

// Post-save hook: runs after successful save
userSchema.post('save', function(doc, next) {
  console.log(`User ${doc.name} has been saved successfully`);
  // Perform side effects: send email, trigger analytics, etc.
  next();
});

// Post-find hook for logging
userSchema.post('find', function(docs) {
  console.log(`Query returned ${docs.length} documents`);
});

// Handle errors in post middleware
userSchema.post('save', function(error, doc, next) {
  if (error.name === 'MongoServerError' && error.code === 11000) {
    // Duplicate key error handling
    console.error('Duplicate email detected');
    next(new Error('A user with this email already exists'));
  } else {
    next(error);
  }
});

Query Middleware vs Document Middleware

Middleware can target queries (find, findOne, updateOne, deleteOne when called on the model) or documents (save, validate, remove when called on an instance). Use the options { document: true } or { query: true } to disambiguate when the same method name applies to both contexts.

Virtuals

Virtuals are computed properties that are not persisted to the database. They are useful for formatting, combining fields, or exposing derived values.

const userSchema = new Schema({
  firstName: String,
  lastName: String,
  dateOfBirth: Date
});

// Virtual for full name
userSchema.virtual('fullName').get(function() {
  return `${this.firstName} ${this.lastName}`;
});

// Virtual with a setter
userSchema.virtual('fullName').set(function(fullName) {
  const parts = fullName.split(' ');
  this.firstName = parts[0];
  this.lastName = parts.slice(1).join(' ');
});

// Virtual for age calculation
userSchema.virtual('age').get(function() {
  const today = new Date();
  const birth = this.dateOfBirth;
  if (!birth) return null;
  let age = today.getFullYear() - birth.getFullYear();
  const monthDiff = today.getMonth() - birth.getMonth();
  if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
    age--;
  }
  return age;
});

// Ensure virtuals are included in JSON output and console.log
userSchema.set('toJSON', { virtuals: true });
userSchema.set('toObject', { virtuals: true });

Virtuals shine when combined with population or when you want to expose computed fields in API responses without storing redundant data.

Query Building and Indexing

Advanced Query Patterns

// Chaining multiple query modifiers
const results = await User.find({ age: { $gte: 18 } })
  .where('bio').ne(null)
  .select('name email age')
  .sort('-createdAt')           // descending by createdAt
  .skip(20)                     // skip first 20 results (pagination)
  .limit(10)
  .lean()
  .exec();

// Using Query.prototype.then() - queries are thenable
const count = await User.countDocuments({ age: { $gt: 30 } });

// Distinct values
const uniqueAges = await User.distinct('age');

// Aggregation pipeline via Model.aggregate()
const aggregationResult = await User.aggregate([
  { $match: { age: { $gte: 20 } } },
  { $group: { _id: '$age', count: { $sum: 1 } } },
  { $sort: { count: -1 } },
  { $limit: 5 }
]);
console.log('Age distribution:', aggregationResult);

Indexes

Define indexes at the schema level to ensure they are created automatically. Indexes dramatically speed up queries on large collections.

const userSchema = new Schema({
  email: { type: String, unique: true },    // unique index
  name: String,
  age: Number,
  city: String,
  createdAt: Date
});

// Compound index for query performance
userSchema.index({ city: 1, age: -1 });

// Text index for full-text search
userSchema.index({ bio: 'text', name: 'text' });

// Unique compound index
userSchema.index({ email: 1, accountType: 1 }, { unique: true });

// TTL index (automatically delete documents after a period)
userSchema.index({ createdAt: 1 }, { expireAfterSeconds: 3600 * 24 * 30 }); // 30 days

// Background index creation (for production)
userSchema.index({ age: 1 }, { background: true });

Mongoose automatically syncs indexes in development, but in production, it's safer to manage indexes via migrations or the autoIndex: false option and create them manually.

Population: Working with References

Population is Mongoose's killer feature—it automatically replaces document references with the actual documents from other collections, similar to SQL joins but for NoSQL.

Basic Population

// Define schemas with references
const authorSchema = new Schema({
  name: String,
  bio: String,
  website: String
});
const Author = mongoose.model('Author', authorSchema);

const bookSchema = new Schema({
  title: { type: String, required: true },
  author: { type: Schema.Types.ObjectId, ref: 'Author' }, // reference
  publishedYear: Number,
  genres: [String]
});
const Book = mongoose.model('Book', bookSchema);

// Create an author and a book referencing them
const author = await Author.create({
  name: 'Jane Austen',
  bio: 'English novelist',
  website: 'https://janeausten.example.com'
});

const book = await Book.create({
  title: 'Pride and Prejudice',
  author: author._id,
  publishedYear: 1813,
  genres: ['Romance', 'Classic']
});

// Populate when querying
const populatedBook = await Book.findOne({ title: 'Pride and Prejudice' })
  .populate('author')
  .exec();

console.log(populatedBook.author.name); // 'Jane Austen'

// Populate with field selection
const bookWithPartialAuthor = await Book.findOne({})
  .populate('author', 'name website') // only include name and website
  .exec();

// Populate multiple paths
const librarySchema = new Schema({
  name: String,
  books: [{ type: Schema.Types.ObjectId, ref: 'Book' }],
  owner: { type: Schema.Types.ObjectId, ref: 'User' }
});
const Library = mongoose.model('Library', librarySchema);

const populatedLibrary = await Library.findOne({})
  .populate('owner')
  .populate({
    path: 'books',
    select: 'title publishedYear',
    options: { sort: { publishedYear: -1 } }
  })
  .exec();

Deep Population (Nested References)

// Nested population: populate fields within populated documents
const chapterSchema = new Schema({
  title: String,
  book: { type: Schema.Types.ObjectId, ref: 'Book' },
  notes: [{ type: Schema.Types.ObjectId, ref: 'Note' }]
});
const Chapter = mongoose.model('Chapter', chapterSchema);

const noteSchema = new Schema({
  content: String,
  author: { type: Schema.Types.ObjectId, ref: 'Author' }
});
const Note = mongoose.model('Note', noteSchema);

// Deep populate
const chapter = await Chapter.findOne({ title: 'Chapter One' })
  .populate({
    path: 'book',
    populate: {
      path: 'author',
      select: 'name'
    }
  })
  .populate({
    path: 'notes',
    populate: {
      path: 'author',
      select: 'name bio'
    }
  })
  .exec();

Populating Across Multiple Levels Dynamically

For maximum flexibility, you can define a recursive populate function or use mongoose-deep-populate plugin for deeply nested structures.

// Dynamic population example
async function populateRecursively(doc, paths, maxDepth = 3) {
  let populated = doc;
  for (let i = 0; i < maxDepth; i++) {
    const options = paths.map(path => ({
      path: path.split('.')[i] || path,
      model: /* infer model from schema */ undefined
    }));
    if (options.every(opt => !opt.path)) break;
    populated = await populated.populate(options).execPopulate
      ? populated.populate(options) // Mongoose 5
      : await doc.constructor.populate(populated, options); // Mongoose 6+
  }
  return populated;
}

Advanced Schema Features

Discriminators

Discriminators enable schema inheritance, allowing you to have multiple models with overlapping schemas stored in the same collection. This is useful for event sourcing, user roles, or product types.

// Base schema for all events
const eventSchema = new Schema({
  timestamp: { type: Date, default: Date.now },
  user: { type: Schema.Types.ObjectId, ref: 'User' }
}, { discriminatorKey: 'eventType' }); // field that distinguishes types

const Event = mongoose.model('Event', eventSchema);

// Derived schemas for specific event types
const clickEventSchema = new Schema({
  elementId: String,
  pageUrl: String
});
const ClickEvent = Event.discriminator('ClickEvent', clickEventSchema);

const purchaseEventSchema = new Schema({
  productId: String,
  amount: Number,
  currency: String
});
const PurchaseEvent = Event.discriminator('PurchaseEvent', purchaseEventSchema);

// All discriminators store documents in the same 'events' collection
await ClickEvent.create({
  user: someUserId,
  elementId: 'button-xyz',
  pageUrl: '/home'
});

await PurchaseEvent.create({
  user: someUserId,
  productId: 'prod-123',
  amount: 49.99,
  currency: 'USD'
});

// Query the base model to get all event types
const allEvents = await Event.find({});
// Each document will have an 'eventType' field: 'ClickEvent' or 'PurchaseEvent'

// Query a specific discriminator
const clicks = await ClickEvent.find({ pageUrl: '/home' });

Plugins

Plugins are reusable schema extensions. You can package common functionality like timestamps, soft deletes, or pagination into plugins and apply them across your schemas.

// Create a plugin for soft delete functionality
function softDeletePlugin(schema, options) {
  schema.add({ 
    isDeleted: { type: Boolean, default: false },
    deletedAt: Date
  });

  schema.pre('find', function() {
    if (!this.getOptions().includeDeleted) {
      this.where({ isDeleted: false });
    }
  });

  schema.pre('findOne', function() {
    if (!this.getOptions().includeDeleted) {
      this.where({ isDeleted: false });
    }
  });

  schema.statics.softDelete = async function(id) {
    return this.findByIdAndUpdate(id, {
      isDeleted: true,
      deletedAt: new Date()
    });
  };

  schema.methods.restore = async function() {
    this.isDeleted = false;
    this.deletedAt = undefined;
    return this.save();
  };
}

// Apply plugin to any schema
const postSchema = new Schema({
  title: String,
  content: String
});

postSchema.plugin(softDeletePlugin);

const Post = mongoose.model('Post', postSchema);

// Usage
const post = await Post.create({ title: 'Hello', content: 'World' });
await Post.softDelete(post._id); // soft delete
const visiblePosts = await Post.find({}); // excludes deleted
const allPosts = await Post.find({}).setOptions({ includeDeleted: true }); // includes deleted

Timestamps

Enable automatic createdAt and updatedAt fields by passing timestamps: true in the schema options. Mongoose automatically manages these fields for you.

const schema = new Schema({
  title: String
}, {
  timestamps: true,           // adds createdAt and updatedAt
  // timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } // custom field names
});

Schema Options Reference

const schema = new Schema({
  // fields
}, {
  timestamps: true,
  toJSON: { virtuals: true, getters: true },
  toObject: { virtuals: true },
  discriminatorKey: '__type',
  collection: 'custom_collection_name',
  autoIndex: process.env.NODE_ENV !== 'production',
  optimisticConcurrency: true, // enables versionKey-based optimistic locking
  strict: true,               // only fields in schema are saved (default: true)
  minimize: true,             // remove empty objects (default: true)
  id: true                    // virtual getter for _id as hex string
});

Error Handling and Debugging

Mongoose errors come in several flavors: validation errors, cast errors, and MongoDB errors. Proper handling prevents crashes and provides meaningful feedback to users.

// Centralized error handling middleware (Express example)
app.post('/users', async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json(user);
  } catch (error) {
    if (error instanceof mongoose.Error.ValidationError) {
      // Extract field-specific validation messages
      const messages = Object.values(error.errors).map(e => e.message);
      return res.status(400).json({ 
        error: 'Validation failed', 
        details: messages 
      });
    }
    
    if (error instanceof mongoose.Error.CastError) {
      return res.status(400).json({ 
        error: 'Invalid data type',
        field: error.path,
        value: error.value
      });
    }
    
    if (error.code === 11000) {
      // Duplicate key error from MongoDB
      const field = Object.keys(error.keyValue)[0];
      return res.status(409).json({ 
        error: `Duplicate value for field: ${field}` 
      });
    }
    
    // Pass unknown errors to global handler
    next(error);
  }
});

Debug Mode

Enable Mongoose debug logging to see all queries in the console. This is invaluable during development.

// Enable debug globally
mongoose.set('debug', true);

// Or enable only for specific environments
if (process.env.NODE_ENV === 'development') {
  mongoose.set('debug', true);
}

// Custom debug function
mongoose.set('debug', (collectionName, methodName, query, doc) => {
  console.log(`Mongoose: ${collectionName}.${methodName}`, 
    JSON.stringify(query, null, 2), doc || '');
});

Best Practices and Performance Optimization

1. Use lean() for Read-Only Queries

When you only need plain JavaScript objects (e.g., for API responses), use .lean(). It bypasses Mongoose's document hydration, returning faster and using less memory.

const users = await User.find({}).lean();
// users are plain objects, no setters/getters, no virtuals by default

2. Limit Fields with select()

Always project only the fields you need, especially for large documents.

const users = await User.find({}).select('name email').lean();

3. Use Indexes Strategically

Analyze your query patterns and create indexes that support them. Use explain() to understand query plans.

const explanation = await User.find({ email: 'alice@example.com' }).explain('executionStats');
console.log(explanation.executionStats);

4. Batch Operations

When dealing with many documents, use bulk operations for better performance.

const bulkOps = [
  { insertOne: { document: { name: 'User1', email: 'user1@test.com' } } },
  { insertOne: { document: { name: 'User2', email: 'user2@test.com' } } },
  { updateOne: { filter: { name: 'Alice' }, update: { $set: { age: 30 } } } }
];
await User.bulkWrite(bulkOps);

5. Connection Pool Management

Mongoose manages a connection pool (default size 100). Adjust based on your workload:

mongoose.connect(MONGO_URI, {
  maxPoolSize: 50,           // limit concurrent connections
  minPoolSize: 10,           // maintain minimum idle connections
  serverSelectionTimeoutMS: 5000, // timeout for server selection
  socketTimeoutMS: 45000,    // close sockets after 45s of inactivity
  family: 4                  // force IPv4
});

6. Use Transactions for Consistency

For operations that must be atomic across multiple collections, use MongoDB transactions (requires replica set).

const session = await mongoose.startSession();
session.startTransaction();

try {
  const user = await User.create([{ name: 'Transactional User' }], { session });
  const order = await Order.create([{ 
    userId: user[0]._id, 
    amount: 99.99 
  }], { session });
  
  await session.commitTransaction();
  console.log('Transaction committed successfully');
} catch (error) {
  await session.abortTransaction();
  console.error('Transaction aborted:', error);
} finally {
  await session.endSession();
}

7. Avoid Global Plugins Unless Necessary

Applying plugins globally via mongoose.plugin() affects all schemas. Use schema-specific plugins for modularity.

8. Handle Connection Errors Gracefully

Implement retry logic and circuit breakers for production resilience.

const connectWithRetry = async (retries = 5, delay = 5000) => {
  for (let i = 0; i < retries; i++) {
    try {
      await mongoose.connect(MONGO_URI);
      console.log('Connected successfully');
      return;
    } catch (error) {
      console.log(`Connection attempt ${i + 1} failed: ${error.message}`);
      if (i < retries - 1) {
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  throw new Error('Failed to connect after multiple retries');

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles