Memos is a lightweight, self-hosted note-taking tool built with Go and React. Here's why it matters and how it works under the hood.
· 6 min read

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 fits: an open-source memo and microblog app with a Go backend and a React frontend.

It stores everything in SQLite. You own your data, there’s no subscription, and the whole thing is a single binary you can run with Docker and forget about. That would be enough on its own, but it’s also a tidy example of how to structure a Go web application, which is the part I actually want to talk about.

What is Memos?

Memos sits somewhere between a notes app and a private microblog. You write short memos in markdown, tag them, and search them later. Think of it as a personal social network for your own thoughts, minus the audience. It’s fast partly because it uses SQLite, so there’s no Postgres or MySQL to set up.

The architecture is straightforward: a Go backend with a clean API layer, a React single-page app on the front, SQLite by default (PostgreSQL and MySQL are optional), and the whole lot deployed as one 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 survives container restarts.

How Memos uses Go under the hood

A few patterns from the codebase are worth pinching for 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
}

The shape here, pulling the user out of context and delegating to a store layer, keeps handlers thin and testable. If context is still a bit fuzzy for you, check out this post on context.

SQLite storage layer

Defaulting to SQLite is, I think, the smartest decision in the project. 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. Write-Ahead Logging gives you much better behaviour when multiple goroutines read from the database concurrently. It’s one line, it’s easy to forget, and it’s often the difference between SQLite feeling like a toy and feeling like a proper database.

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, I’d reach for 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 write once and use everywhere. Goldmark is fast, well maintained, and has extensions for tables and task lists when you need them.

Why Go is a great fit for self-hosted apps

Self-hosted software has a particular set of constraints. It runs on hardware you don’t control, often a cheap VPS or a Raspberry Pi, installed by people who have no interest in debugging your runtime. Go suits this unusually well. You ship one binary with no runtime dependencies. Memory stays low enough for small containers. Goroutines handle multiple users without any threading gymnastics, and the server is ready in milliseconds.

A static binary plus an SQLite file is about as simple as a deployment story gets. If you’re picking a language for a self-hosted tool, that combination is hard to argue with.

Concurrency is also the bit most likely to bite you in an app like this. If several users hit your server at once and you’ve been careless with goroutines, things leak quietly. Worth reading about common goroutine leaks and how to avoid them before it happens to you.

Patterns worth borrowing

Even if you never run Memos, the codebase has lessons in it. The store abstraction keeps the database layer cleanly separated from the API layer, which is exactly what makes swapping SQLite for PostgreSQL feasible. Defining the API in protobuf first buys type safety, documentation and generated code in one move. Storage backends plug in without polluting the core logic. And everything, Go backend, React frontend and SQLite included, ships in a single Docker image, which is the deployment experience every self-hosted app should aim for.

Wrapping up

Memos solves a real problem without ceremony: quick note capture that doesn’t hand your data to someone else. SQLite for simplicity, markdown for content, Docker for deployment.

If you want the tool, run it. If you want to learn how to build Go web applications, read the source instead. Start at the entry point, follow the store and API layers, and give it half an hour. You’ll get more out of it than most tutorials.