A self-hostable, multilingual blog platform you can deploy in minutes.
- Articles β create and publish posts with a rich-text editor (bold, italic, headings, links, images, lists, tables); cover image, per-language translations, custom slugs, tags, author, and publish date
- Custom pages β build free-form pages with the visual block editor and link them in the navigation
- Media library β upload images and files; pick them directly inside the article editor or from any block inspector
- Tags β manage content tags in Settings β General; reflected live in the article editor and public tag filter
- Contact submissions β view and manage enquiries submitted through the public contact form
- Newsletter β view and export the subscriber list; subscribers opt in via the public unsubscribe page
- WYSIWYG block editor β drag-and-drop canvas with real-time theme preview; used for the Home page, Header, Footer, and all custom pages
- Block palette β three categories of blocks:
- Layout: Container, Slideshow
- Content: Text, Image, Button
- Templates: Hero, CTA Band, Rich Text, Image + Text, Testimonials, Newsletter Subscribe, Featured Articles, Latest Articles
- Article blocks β Article Grid and Article Card blocks with configurable field slots (image, title, excerpt, date, tag) for embedding article lists anywhere on a page
- Header & footer builders β dedicated builders with Nav Links, Sub-navigation, Social Links, and preset blocks; live preview with your active theme applied
- Layers panel β tree view of all blocks with reorder (drag or arrow buttons), hide/show toggle, and delete
- Per-language content β every block's text fields are independently translated; copy all translations from another language in one click
- N languages β add any number of languages (BCP-47 codes, LTR/RTL) in Settings β Languages
- Per-article translations β each article can have a full translation per language with its own title, body, and slug
- Per-nav-link labels β override navigation link labels for each language via the π popover in Settings β Navigation
- UI string translations β translate every built-in page label, button, and message in Settings β Translations; falls back to English when a string is not set
- Static output β Eleventy generates a fast, SEO-friendly static site, rebuilt after every admin save
- Built-in pages β Home, Articles (with tag filter), Contact form, Unsubscribe
- Favicon & logo β configure a browser tab favicon and a header logo image that replaces the site name in the nav
- 4 bundled presets β
default,dark,minimal,warm; swap instantly from Settings β Theme - Live preview β colour and font changes are previewed in the admin UI before saving
- Full customisation β 15 colour tokens, body font + heading font + fallback stack, button / card / input border radii
- Go backend β Echo v4, SQLite (WAL mode via
modernc.org/sqlite), JWT authentication, optimistic concurrency - React admin UI β Vite + React 18 + TanStack Query v5 + Tailwind v4
- Docker-ready β single image published to GitHub Container Registry; deploy with
docker composeor the ONCE app server
- Go 1.22+
- Node.js 20+
- Make
Copy the example env file and edit secrets:
cp .env.example .env
# Edit .env β set a strong JWT_SECRET at minimumEdit config.yaml to set your site name and languages:
site:
name: "My Blog"
languages:
- code: en
label: English
dir: ltr
default: truemake setupThis runs go mod tidy, npm install, database migrations, and prompts you to create an admin account.
make dev| Service | URL |
|---|---|
| Backend API | http://localhost:8080 |
| Admin UI | http://localhost:5173/admin |
| Public site (Eleventy) | http://localhost:8081 |
Log in at /admin. The sidebar gives access to:
| Section | Description |
|---|---|
| Dashboard | Recent activity overview |
| Articles | Create, edit, and publish posts |
| Pages | Manage custom pages built with the block editor |
| Media | Upload and browse images/files |
| Contacts | View contact form submissions |
| Newsletter | View and export subscriber list |
| Home Layout | Design the home page with the visual block editor |
| Header Layout | Build the site header with nav and branding blocks |
| Footer Layout | Build the site footer with links and social blocks |
| Settings | Site-wide configuration (see below) |
| Tab | Description |
|---|---|
| General | Site name, tagline, public URL, booking URL, contact email, content tags, favicon, and header logo |
| Navigation | Build the main navigation bar; choose built-in pages, custom pages, or external URLs; reorder with β²βΌ; set per-language labels with π |
| Footer & Social | Footer links (same options as Navigation) and social media profile links shown in the footer |
| Theme | Choose a colour preset or fine-tune 15 colour tokens, body font, and border radii; changes preview live in the admin |
| Languages | Add or remove site languages; set the default locale; supports LTR and RTL scripts |
| Translations | Translate every built-in page label, button, and message for each configured language; blank fields fall back to English |
# Set DOMAIN= in your .env or override on the command line
DOMAIN=myblog.example.com docker compose up -dThe docker-compose.yml wires together:
- backend β compiled Go binary
- site-builder β Eleventy build (runs once at startup, then on each rebuild trigger)
- proxy β Caddy reverse proxy (automatic HTTPS when
DOMAINis set)
ONCE is an open-source app server by 37signals that lets you run multiple self-hosted web apps on a single machine via a simple terminal UI β no DevOps knowledge required. folio is built to run on it out of the box.
Install the ONCE CLI on any Linux server with one command, then point it at the folio Docker image. ONCE handles:
- Automatic TLS certificates (via Kamal Proxy)
- Zero-downtime updates
- Daily backups to a local directory (30-day retention)
- A dashboard showing CPU, memory, traffic, and unique visitors
- Running multiple apps side-by-side on the same machine
Any Linux VPS works (Ubuntu 22.04+ or Debian 12 recommended).
| Scale | RAM | CPU |
|---|---|---|
| Personal / small team | 1 GB | 1 vCPU |
| Medium traffic | 2 GB | 2 vCPU |
| High traffic | 4 GB+ | 4+ vCPU |
Providers: Hetzner, DigitalOcean, Linode, AWS, Vultr β anything works.
- A domain name pointed at your server's IP address (DNS A record).
- Docker installed on the server:
curl -fsSL https://get.docker.com | sh- ONCE CLI installed:
curl https://get.once.com | shThe official image is published to GitHub Container Registry on every tagged release:
docker pull ghcr.io/vl4d1m1r4/folio:latestOr build and push your own image:
docker build -t ghcr.io/vl4d1m1r4/folio:latest .
docker push ghcr.io/vl4d1m1r4/folio:latestRun once on your server, choose Custom Docker image, and enter:
Image URL: ghcr.io/vl4d1m1r4/folio:latest
Hostname: blog.example.com
ONCE will pull the image, configure the proxy, and provision a TLS certificate automatically.
From the ONCE dashboard, open your app's Settings β Environment and add:
JWT_SECRET=<at-least-32-random-characters>
DB_PATH=/storage/blog.db
UPLOAD_DIR=/storage/uploadsONCE mounts
/storageas a persistent volume. Keeping all data there means backups and restores work automatically.
SSH into the server and run:
docker exec -it <container-name> ./create-adminThe container name is shown in the ONCE dashboard.
Log in at https://blog.example.com/admin and use Settings to set your
site name, languages, navigation, and theme.
ONCE checks for a new image once every 24 hours and redeploys with zero downtime automatically. You can also trigger an immediate update from the dashboard.
Enable automatic backups in ONCE dashboard β Settings β Backups by specifying
a local path. ONCE saves a daily snapshot of /storage and keeps the last 30 days.
For full control or if you're not using ONCE:
# Set DOMAIN= in your .env or override on the command line
DOMAIN=myblog.example.com docker compose up -dThe docker-compose.yml wires together:
- backend β compiled Go binary
- site-builder β Eleventy build (runs once at startup, then on each rebuild trigger)
- proxy β Caddy reverse proxy (automatic HTTPS when
DOMAINis set)
| Key | Description |
|---|---|
site.name |
Blog name shown in nav and <title> |
site.tagline |
Short descriptor shown in the header |
site.url |
Canonical public URL (used for sitemaps and og:url) |
site.headline |
Hero headline on the home page |
site.bookingUrl |
Optional call-to-action URL |
site.contactEmail |
Email shown in CTA band and footer |
languages |
Ordered array of {code, label, dir, default} objects |
tags |
Seed list of content tags (overridden by DB value once saved) |
All values in
config.yamlare the initial seed. After the first save in Settings β General, the database value takes precedence.
CSS custom properties applied to every public page. Pick a preset from themes/:
cp themes/dark.json theme.jsonOr edit theme.json directly, or use Settings β Theme in the admin. Key tokens:
| Token | Description |
|---|---|
colors.accent |
Primary action colour (buttons, links) |
colors.bg |
Page background |
colors.text |
Body text |
colors.nav-from/to |
Navigation bar gradient |
fonts.body |
Body font family name |
radius.button/card/input |
Border radius for each element type |
| Variable | Default | Description |
|---|---|---|
JWT_SECRET |
(required) | Secret for signing admin tokens (min 32 chars) |
DB_PATH |
./blog.db |
SQLite database path |
PORT |
8080 |
Backend listen port |
UPLOAD_DIR |
./uploads |
Uploaded media directory |
EMAIL_PROVIDER |
msgraph |
Email transport: smtp or msgraph |
SMTP_HOST |
β | SMTP server hostname (e.g. smtp.gmail.com) |
SMTP_PORT |
587 |
SMTP port (587 for STARTTLS, 465 for implicit TLS) |
SMTP_USER |
β | SMTP auth username |
SMTP_PASS |
β | SMTP auth password (use an app password for Gmail) |
SMTP_SENDER |
β | From address for outgoing email |
MS_GRAPH_TENANT_ID |
β | Azure AD tenant ID (MS Graph provider) |
MS_GRAPH_CLIENT_ID |
β | Azure AD app client ID (MS Graph provider) |
MS_GRAPH_CLIENT_SECRET |
β | Azure AD app client secret (MS Graph provider) |
MS_GRAPH_SENDER |
β | Sender mailbox UPN (MS Graph provider) |
GOATCOUNTER_URL |
β | GoatCounter analytics endpoint injected into pages |
Email is used to notify you when someone submits the contact form. Configure one provider via EMAIL_PROVIDER. When no provider is configured emails are silently skipped (useful during development).
The current provider status is shown read-only in Admin β Settings β General β Email delivery.
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@gmail.com
SMTP_PASS=xxxx xxxx xxxx xxxx
SMTP_SENDER=you@gmail.comWorks with any standard SMTP relay. Port 587 uses STARTTLS; port 465 uses implicit TLS.
Gmail requires an app password instead of your account password when 2-Step Verification is enabled:
- Go to Google Account β Security β 2-Step Verification β App passwords
- Create a new app password (name it anything, e.g. folio)
- Copy the 16-character password (spaces are optional) into
SMTP_PASS
Other common providers:
| Provider | SMTP_HOST |
SMTP_PORT |
|---|---|---|
| Gmail | smtp.gmail.com |
587 |
| Outlook / Hotmail | smtp.office365.com |
587 |
| Brevo (Sendinblue) | smtp-relay.brevo.com |
587 |
| Mailgun | smtp.mailgun.org |
587 |
| Amazon SES | email-smtp.<region>.amazonaws.com |
587 |
| Postmark | smtp.postmarkapp.com |
587 |
EMAIL_PROVIDER=msgraph
MS_GRAPH_TENANT_ID=<your-tenant-id>
MS_GRAPH_CLIENT_ID=<your-client-id>
MS_GRAPH_CLIENT_SECRET=<your-client-secret>
MS_GRAPH_SENDER=notifications@yourcompany.comRequires an Azure AD app registration with the Mail.Send application permission granted and admin-consented.
AGPL-3.0 license