Skip to Content

Production Guide: Deploy Memos with Docker Compose + Caddy + PostgreSQL on Ubuntu

A privacy-first self-hosted notes platform with TLS, PostgreSQL backend, and full API access

Personal notes scattered across sticky apps, cloud services, and shared drives are a liability — you never own the data, the search is mediocre, and one deleted account wipes your history. Memos is a lightweight, privacy-first self-hosted notes and micro-journalling platform. It supports Markdown, tags, #hashtags, a clean timeline view, and a full REST API. This guide walks you through a production-grade deployment of Memos on Ubuntu using Docker Compose, Caddy as a reverse proxy with automatic TLS, and PostgreSQL as the database backend — so your notes infrastructure runs reliably on infrastructure you control.

Architecture and flow overview

Incoming HTTPS requests arrive at Caddy on port 443. Caddy terminates TLS (via Let's Encrypt or ZeroSSL) and reverse-proxies to the Memos container on its internal port 5230. Memos stores all notes, tags, and user data in a PostgreSQL container. All three services — Caddy, Memos, and PostgreSQL — share a dedicated Docker bridge network and are managed by a single docker-compose.yml.

Internet → Caddy :443 (TLS termination)
                  ↓
           Memos :5230 (app)
                  ↓
           PostgreSQL :5432 (data)

Caddy handles certificate renewal automatically. Memos does not expose any port to the host — only Caddy reaches it through the internal Docker network.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with at least 1 vCPU and 1 GB RAM
  • A public domain name pointed at the server's IP (A record resolving before you start)
  • Docker Engine 24+ and Docker Compose v2 plugin installed
  • Ports 80 and 443 open in UFW and any upstream firewall
  • openssl available for secret generation

Step-by-step deployment

1. Create the project directory

mkdir -p /opt/memos && cd /opt/memos
mkdir -p pgdata

2. Generate PostgreSQL credentials

MEMOS_DB_USER=memos
MEMOS_DB_NAME=memos
MEMOS_DB_PASS=$(openssl rand -hex 24)
echo "DB_PASS=$MEMOS_DB_PASS"

Save the generated password — you will paste it into the .env file in the next step.

3. Create the environment file

cat > /opt/memos/.env << 'EOF'
# PostgreSQL
POSTGRES_USER=memos
POSTGRES_PASSWORD=REPLACE_WITH_YOUR_PASSWORD
POSTGRES_DB=memos

# Memos
MEMOS_DOMAIN=notes.yourdomain.com
EOF

4. Create the Docker Compose file

cat > /opt/memos/docker-compose.yml << 'EOF'
version: "3.9"

networks:
  memos_net:
    driver: bridge

volumes:
  pgdata:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - memos_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

  memos:
    image: neosmemo/memos:stable
    restart: unless-stopped
    environment:
      MEMOS_DRIVER: postgres
      MEMOS_DSN: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
    networks:
      - memos_net
    depends_on:
      postgres:
        condition: service_healthy

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_logs:/var/log/caddy
    networks:
      - memos_net
    depends_on:
      - memos

volumes:
  pgdata:
  caddy_data:
  caddy_logs:
EOF

5. Create the Caddyfile

cat > /opt/memos/Caddyfile << 'EOF'
notes.yourdomain.com {
    reverse_proxy memos:5230
    encode gzip
    log {
        output file /var/log/caddy/memos.log
    }
}
EOF

Replace notes.yourdomain.com with your actual domain in both the .env and Caddyfile.

6. Open firewall ports

ufw allow 80/tcp
ufw allow 443/tcp
ufw reload

7. Start the stack

cd /opt/memos
docker compose up -d
docker compose logs -f

Watch the logs for Caddy to obtain a TLS certificate (usually within 30 seconds on the first start) and for Memos to complete database migrations.

Configuration and secrets handling

All credentials are stored in the .env file which Docker Compose reads automatically. Never commit this file to version control. Restrict permissions so only root can read it:

chmod 600 /opt/memos/.env

The PostgreSQL data directory pgdata and the Caddy caddy_data volume hold your TLS certificates and all note data. Back both up regularly. For offsite backups, use pg_dump piped to an encrypted S3-compatible store. Memos itself does not expose secrets in its environment outside the DSN string — keep the .env file tightly controlled.

To rotate the PostgreSQL password, update .env, run docker compose down, update the password in PostgreSQL directly (ALTER USER memos WITH PASSWORD '...'), then docker compose up -d to reconnect.

Verification

After the stack starts, verify each layer:

# Check all containers are running
docker compose ps

# Confirm PostgreSQL is healthy
docker compose exec postgres pg_isready -U memos

# Test HTTP → HTTPS redirect
curl -I http://notes.yourdomain.com

# Verify TLS certificate
curl -v https://notes.yourdomain.com 2>&1 | grep "SSL connection"

Open https://notes.yourdomain.com in a browser. You should see the Memos setup screen on first launch — create your admin account there. After login, write a test note using #test and confirm it appears in the timeline.

Common issues and fixes

Memos fails to start with DSN connection error: The most common cause is that PostgreSQL is not yet healthy when Memos starts. The depends_on: condition: service_healthy in the Compose file handles this, but if you see repeated connection errors in the Memos logs, wait 20–30 seconds and then run docker compose restart memos.

Caddy returns 502 Bad Gateway: Caddy is running but cannot reach the Memos container. Verify both services are on the same Docker network (docker network inspect memos_memos_net) and that the Caddyfile uses the service name memos as the upstream host. The service name must match the key in docker-compose.yml.

TLS certificate not issued: Ensure ports 80 and 443 are reachable from the internet (check UFW and your cloud provider's security group). Caddy requires port 80 for the ACME HTTP-01 challenge even if you only serve HTTPS. Also confirm your domain's A record resolves to the server's public IP before starting the stack.

Data loss after docker compose down -v: The -v flag removes named volumes including PostgreSQL data. For routine restarts, use docker compose down (without -v) to preserve data volumes. Treat volume removal as a destructive operation.

Memos API returns 401 on all requests: Memos uses token-based auth. Generate an access token in the Memos UI under Settings → Access Tokens and pass it as a Bearer header in API calls.

FAQ

Can I migrate data from the default SQLite setup to PostgreSQL?

Memos does not provide a built-in migration tool from SQLite to PostgreSQL. The recommended approach is to export all memos as Markdown files via the Memos export feature, spin up the PostgreSQL-backed stack, and re-import. For large datasets, the Memos REST API can be scripted to batch-create notes from exported JSON.

How do I update Memos to a newer version?

Pull the updated image and recreate the container: docker compose pull memos && docker compose up -d memos. Memos runs database migrations automatically on startup. Always read the release notes before upgrading major versions, as schema changes may require a backup beforehand.

Can multiple users share the same Memos instance?

Yes. Memos supports multi-user mode. Each user has a separate timeline and note space. The admin can control whether new signups are open or invite-only via Settings → System. For team use, disable public signups after creating user accounts.

How do I back up the Memos database?

Use pg_dump from the PostgreSQL container and store the dump offsite:

docker compose exec postgres pg_dump -U memos memos | gzip > /opt/backups/memos_$(date +%F).sql.gz

Schedule this with a cron job and rotate old dumps. Restore with gunzip -c dump.sql.gz | docker compose exec -T postgres psql -U memos memos.

Is Memos accessible via mobile?

Memos has a responsive web UI that works well on mobile browsers. There are also community-maintained iOS and Android apps that connect to your self-hosted instance using the Memos REST API. The official Memos PWA (Progressive Web App) can be installed from the browser for a near-native experience on most mobile platforms.

How do I enable SMTP notifications or email verification in Memos?

Memos supports SMTP for email notifications and optional user email verification. Configure SMTP settings in the Memos admin panel under Settings → System → Email. You will need an SMTP relay (e.g., Resend, Mailgun, or your own Postfix relay). Set the MEMOS_SMTP_* environment variables in your .env file and restart the Memos container to apply changes.

Internal links

Talk to us

If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.

Contact Us

Production Guide: Deploy Mealie with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host your recipe manager and meal planner with automatic TLS and a PostgreSQL backend