Memos: the self-hosted note-taking app written in Go
I’ve been looking for a simple, self-hosted note-taking tool for a while. Something markdown-native, lightweight, and not tied to someone else’s cloud. Memos is exactly that — an open-source, FOSS memo and microblog app built with Go on the backend and React on the frontend.
It stores everything in SQLite. You own your data. No vendor lock-in. No subscription. Just a single binary you can run with Docker and forget about.
Let’s look at what makes it interesting from a Go perspective.
What is Memos?
Memos is a self-hosted notecard and note-taking tool. Think of it as a personal social network for your thoughts. You write short memos in markdown, tag them, and search them later. It’s fast because it uses SQLite — no Postgres or MySQL setup required.
The architecture is straightforward:
- Backend: Go with a clean API layer
- Frontend: React single-page app
- Storage: SQLite by default (with optional PostgreSQL/MySQL support)
- Deployment: Single Docker container
If you like the own-your-data philosophy, this is right up your alley.
Running Memos with Docker
The fastest way to get Memos running is with Docker:
docker run -d \
--name memos \
-p 5230:5230 \
-v ~/.memos/:/var/opt/memos \
neosmemo/memos:stable
That’s it. Open http://localhost:5230 and you have a working instance. Your data lives in ~/.memos/ on the host, so it persists across container restarts.
How Memos Uses Go Under the Hood
Memos is a good example of how to structure a Go web application. Let’s look at a few patterns it uses that you can apply to your own projects.
Clean API Design with Protocol Buffers
Memos defines its API using protobuf and gRPC, then exposes it over HTTP via gRPC-Gateway. Here’s a simplified version of how a memo service might look:
package api
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// MemoService handles memo-related operations.
type MemoService struct {
Store *Store
}
// CreateMemo creates a new memo for the authenticated user.
func (s *MemoService) CreateMemo(ctx context.Context, req *CreateMemoRequest) (*Memo, error) {
user, err := getCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "failed to get user")
}
memo := &Memo{
CreatorID: user.ID,
Content: req.Content,
Visibility: req.Visibility,
}
if err := s.Store.CreateMemo(ctx, memo); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo: %v", err)
}
return memo, nil
}
This pattern — extracting the user from context and delegating to a store layer — keeps handlers thin and testable. If you want to learn more about how context works in Go, check out this post on context.
SQLite Storage Layer
One of the smartest decisions in Memos is using SQLite as the default database. No external dependencies. No connection strings. Just a file on disk.
Here’s how you might implement a simple store for memos using the database/sql package:
package store
import (
"context"
"database/sql"
"time"
_ "modernc.org/sqlite"
)
// Store wraps the database connection.
type Store struct {
db *sql.DB
}
// NewStore opens a SQLite database and returns a Store.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// Enable WAL mode for better concurrent read performance
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
// Memo represents a single note.
type Memo struct {
ID int
Content string
CreatedAt time.Time
}
// CreateMemo inserts a new memo into the database.
func (s *Store) CreateMemo(ctx context.Context, content string) (*Memo, error) {
result, err := s.db.ExecContext(ctx,
"INSERT INTO memo (content, created_ts) VALUES (?, ?)",
content, time.Now().Unix(),
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &Memo{
ID: int(id),
Content: content,
CreatedAt: time.Now(),
}, nil
}
// ListMemos returns all memos ordered by creation time.
func (s *Store) ListMemos(ctx context.Context) ([]*Memo, error) {
rows, err := s.db.QueryContext(ctx,
"SELECT id, content, created_ts FROM memo ORDER BY created_ts DESC",
)
if err != nil {
return nil, err
}
defer rows.Close()
var memos []*Memo
for rows.Next() {
var m Memo
var ts int64
if err := rows.Scan(&m.ID, &m.Content, &ts); err != nil {
return nil, err
}
m.CreatedAt = time.Unix(ts, 0)
memos = append(memos, &m)
}
return memos, rows.Err()
}
Notice the PRAGMA journal_mode=WAL call. This enables Write-Ahead Logging, which gives you much better performance when multiple goroutines read from the database concurrently. It’s a small detail that matters a lot in production.
Markdown Parsing
Since Memos is markdown-native, it needs to parse and render markdown content. The project uses a custom parser, but if you’re building something similar, you might reach for a library like goldmark:
package main
import (
"bytes"
"fmt"
"log"
"github.com/yuin/goldmark"
)
func renderMarkdown(source string) (string, error) {
var buf bytes.Buffer
md := goldmark.New()
if err := md.Convert([]byte(source), &buf); err != nil {
return "", fmt.Errorf("failed to convert markdown: %w", err)
}
return buf.String(), nil
}
func main() {
input := "# Hello\n\nThis is a **memo** with some `code`."
html, err := renderMarkdown(input)
if err != nil {
log.Fatal(err)
}
fmt.Println(html)
}
This is the kind of utility function you’ll write once and use everywhere. Goldmark is fast, well-maintained, and supports extensions like tables and task lists.
Why Go is a Great Fit for Self-Hosted Apps
Memos is a perfect example of why Go works so well for self-hosted software:
- Single binary: No runtime dependencies. Compile once, ship everywhere.
- Low memory footprint: Memos runs comfortably in containers with minimal resources.
- Built-in concurrency: Handles multiple users without complex threading.
- Fast startup: The server is ready in milliseconds, not seconds.
If you’re building a self-hosted tool and trying to decide on a language, Go gives you the simplest deployment story. A static binary plus an SQLite file is about as simple as it gets.
For more on how Go handles concurrency — which is key for apps serving multiple users — you might enjoy reading about common goroutine leaks and how to avoid them.
Patterns Worth Borrowing
Even if you never use Memos, there are patterns in the codebase worth studying:
- Store abstraction: The database layer is cleanly separated from the API layer. This makes it easy to swap SQLite for PostgreSQL.
- Protobuf-first API: Defining your API in protobuf gives you type safety, documentation, and code generation for free.
- Plugin-like architecture: Memos supports different storage backends without polluting the core logic.
- Single Docker image: Everything — Go backend, React frontend, SQLite — ships in one container.
Wrapping Up
Memos is a clean, well-built Go project that solves a real problem: quick note capture without giving your data to someone else. It uses SQLite for simplicity, markdown for content, and Docker for easy deployment.
If you’re looking for a self-hosted note-taking tool, give it a try. If you’re looking to learn how to build Go web applications, read the source. It’s a solid example of how to structure a modern Go backend with clean separation between API, storage, and business logic.