GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured
GitLab CE and Forgejo both give you a private Git server with issues, code review, CI and a container registry. They sit at opposite ends of the weight scale. This comparison ran both under Docker Compose on the same box, pushed a real pipeline through each runner, pushed an image to each registry, migrated the same GitHub repository into both and ran each backup command. The numbers below come from that run on October 19, 2026.
TL;DR:
- On an 8 vCPU host, Forgejo 15.0.10 with Postgres and a runner idled at 285.7 MiB of RAM and under 1% of one CPU core. GitLab CE 19.5.0 with its runner idled at 8,722.6 MiB with default settings. The GitLab container alone dropped to 2,466.5 MiB with the memory-constrained settings from the GitLab docs.
- Forgejo answered its health check 5.4 seconds after docker compose up -d. GitLab served its sign-in page after 145 seconds.
- Both ran a trivial CI job, accepted a docker push to the built-in registry, migrated a GitHub repo and wrote a backup archive of 15.6 MB (Forgejo) or 16.2 MB (GitLab).
- GitLab CE is MIT licensed, with paid features under a separate EE license. Forgejo is GPL v3 or later. Forgejo federation is experimental and off by default.
- Pick Forgejo for a small team or a homelab VPS. Pick GitLab CE when you want its integrated CI/CD and project management and can give it 8 GB or more of RAM.
Table of contents
The measured numbers
Idle RAM and CPU
Each stack ran alone on the same 8 vCPU host. The numbers are idle averages from docker stats, taken after each stack had run a pipeline, a registry push, a migration and a backup. 100% CPU means one full core.
!Bar chart of idle memory on the same 8 vCPU host. Forgejo 15.0.10 stack with server, Postgres and runner: 285.7 MiB. GitLab CE 19.5.0 with memory-constrained settings, GitLab container only: 2,466.5 MiB. GitLab CE 19.5.0 with default settings plus runner: 8,722.6 MiB.
| Stack | Avg RAM | Peak RAM | Avg CPU | Peak CPU |
| --- | --- | --- | --- | --- |
| Forgejo 15.0.10 + Postgres 17.11 + runner 13.2.0 | 285.7 MiB | 300.1 MiB | 0.57% | 0.89% |
| GitLab CE 19.5.0 default + runner 19.5.0 | 8,722.6 MiB | 8,797.6 MiB | 16.50% | 87.05% |
| GitLab CE 19.5.0 memory-constrained (container only) | 2,466.5 MiB | 2,537.5 MiB | 9.29% | 74.12% |
The constrained row leaves out the runner, so add about 65 MiB to compare it with the others. GitLab's default memory also depends on the server. Per the GitLab Puma docs, it starts one Puma worker per CPU, limited by RAM at about 1 GB each, so a 2 vCPU server starts fewer workers than this 8 vCPU host did.
Inside Forgejo, the server process took about 204 MiB, Postgres about 73 MiB and the idle runner about 8 MiB. GitLab is one container with many services. gitlab-ctl status listed 16 of them, including Puma, Sidekiq, Gitaly, PostgreSQL, Redis, Prometheus, Alertmanager, the registry and several exporters. With default settings, ps inside the container showed ten Ruby processes at roughly 1 GB of resident memory each, and the cgroup reported 8,022 MiB of anonymous memory. The GitLab runner added about 65 MiB.
The default GitLab number is in line with what people report on the GitLab forum. In a 2018 thread, one user who had just installed the gitlab-ce container with a single account and no code saw 8.452 GB in docker stats. The installation requirements set 16 GB and 8 vCPU as the single-node baseline, and at least 8 GB for memory-constrained setups.
The memory-constrained profile comes from the GitLab docs page for small environments, which states that "With some adjustments, GitLab can run comfortably on much lower specifications than described in minimum requirements or the reference architectures." The lab applied the page's Puma, Sidekiq, Prometheus and jemalloc settings, plus the Gitaly spawn limit, but not the Gitaly per-repo concurrency block. On this host that cut the GitLab container's idle RAM by about 72%, and a pipeline still ran green. The same page asks for at least 2 GB of RAM plus 1 GB of swap and warns that functionality and performance can degrade.
Forgejo 17.0.0, released October 15, 2026, idled at 315.3 MiB on the same host. As of October 19, 2026, the 17.0.0 release page advises against upgrading because of an issue that "prevents pushing, merging, or other code operations in some cases," so the rest of this guide uses the 15.0 LTS line.
Install time and steps
| | Forgejo 15.0.10 | GitLab CE 19.5.0 |
| --- | --- | --- |
| Images | forgejo 188 MB, postgres 297 MB, runner 46 MB | gitlab-ce 4.07 GB, gitlab-runner 245 MB |
| Pull time on this host | 3 to 7 s per image | 53 s for gitlab-ce |
| up -d to ready | 5.4 s (/api/healthz passes) | 145 s (/users/sign_in returns 200) |
| Admin account | forgejo admin user create | GITLAB_ROOT_PASSWORD on first boot |
| Runner setup | generate token, register, restart runner | create runner in UI or API, register |
Image sizes are the local uncompressed sizes from docker images. GitLab also pulled its helper image, gitlab-runner-helper:x86_64-v19.5.0 (112 MB), on the first job. Switching GitLab to the constrained settings and recreating the container took another 98 seconds before the sign-in page came back.
Install both with Docker Compose
Both stacks below publish ports on 127.0.0.1 only. Put a TLS proxy such as Nginx Proxy Manager in front before anyone else uses them, the same pattern as the Plausible CE guide.
Forgejo with Postgres and a runner
The Forgejo Docker docs start from a single server service and add Postgres as an option. The file below adds Postgres, skips the web installer with INSTALL_LOCK, and adds a runner that waits until it has been registered:
name: fj
services:
server:
image: codeberg.org/forgejo/forgejo:15.0.10
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=db:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=change-me-db-password
- FORGEJO__security__INSTALL_LOCK=true
- FORGEJO__server__ROOT_URL=http://127.0.0.1:3000/
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__actions__ENABLED=true
- FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org
restart: unless-stopped
volumes:
- fj-data:/data
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:2222:22"
depends_on:
- db
db:
image: postgres:17.11-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=change-me-db-password
- POSTGRES_DB=forgejo
volumes:
- fj-pg:/var/lib/postgresql/data
runner:
image: code.forgejo.org/forgejo/runner:13.2.0
user: "0:0"
restart: unless-stopped
command: ["/bin/sh", "-c", "while [ ! -f /data/.runner ]; do sleep 2; done; exec forgejo-runner daemon --config /data/config.yml"]
volumes:
- fj-runner:/data
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- server
volumes:
fj-data:
fj-pg:
fj-runner:
FORGEJO__actions__ENABLED=true and the default actions URL match the documented defaults and are written out so the file explains itself. Forgejo publishes no latest tag at all, by design, so pinning a full version like 15.0.10 is the normal path.
Start it, create an admin and register the runner:
docker compose up -d
docker compose exec -u git server forgejo admin user create --admin \
--username labadmin --email you@example.com --password "$(openssl rand -hex 12)" \
--must-change-password=false
TOKEN=$(docker compose exec -u git server forgejo actions generate-runner-token | tr -d "\r")
docker compose exec runner sh -c "cd /data && forgejo-runner generate-config > config.yml \
&& sed -i \"s/^ network: .*/ network: fj_default/\" config.yml \
&& forgejo-runner register --no-interactive --instance http://server:3000 \
--token $TOKEN --name lab-runner --labels docker:docker://alpine:3.24.2"
docker compose restart runner
The network: fj_default line puts job containers on the Compose network so they can reach http://server:3000. The runner mounts the Docker socket, which gives it root-level control of the host. Run it on a separate machine if untrusted people can push workflows.
GitLab CE with a runner
The GitLab Docker install docs ask for an externally reachable hostname and say not to use localhost. The lab mapped gitlab.lab.test to 127.0.0.1 in /etc/hosts and gave the container the same name as a network alias, so the runner and job containers resolve it too:
name: gl
services:
gitlab:
image: gitlab/gitlab-ce:19.5.0-ce.0
hostname: gitlab.lab.test
restart: unless-stopped
shm_size: "256m"
environment:
GITLAB_ROOT_PASSWORD: ${GITLAB_ROOT_PASSWORD}
GITLAB_OMNIBUS_CONFIG: |
external_url "http://gitlab.lab.test:8929"
registry_external_url "http://gitlab.lab.test:5050"
gitlab_rails["gitlab_shell_ssh_port"] = 2224
ports:
- "127.0.0.1:8929:8929"
- "127.0.0.1:5050:5050"
- "127.0.0.1:2224:22"
volumes:
- gl-config:/etc/gitlab
- gl-logs:/var/log/gitlab
- gl-data:/var/opt/gitlab
networks:
default:
aliases:
- gitlab.lab.test
runner:
image: gitlab/gitlab-runner:alpine-v19.5.0
restart: unless-stopped
volumes:
- gl-runner:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock
volumes:
gl-config:
gl-logs:
gl-data:
gl-runner:
Put GITLAB_ROOT_PASSWORD= with a long random value in a .env file next to it and run docker compose up -d. Without that variable, GitLab writes a generated password to /etc/gitlab/initial_root_password inside the container and deletes it on the first container restart after 24 hours.
For the constrained run, these lines were appended to GITLAB_OMNIBUS_CONFIG and the container recreated:
# memory-constrained settings from docs.gitlab.com/omnibus/settings/memory_constrained_envs
puma["worker_processes"] = 0
sidekiq["concurrency"] = 10
prometheus_monitoring["enable"] = false
gitlab_rails["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000" }
gitaly["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000", "GITALY_COMMAND_SPAWN_MAX_PARALLEL" => "2" }
Then create an instance runner under Admin, CI/CD, Runners (or with POST /api/v4/user/runners), copy the glrt- token and register:
docker compose exec runner gitlab-runner register --non-interactive \
--url http://gitlab.lab.test:8929 --token glrt-REPLACE_ME \
--executor docker --docker-image alpine:3.24.2 --docker-network-mode gl_default
CI, registry, issues and merge requests
GitLab CI and Forgejo Actions
Each instance got a hello-ci repository with the same two-step job on alpine:3.24.2. The GitLab version lives in .gitlab-ci.yml:
hello:
image: alpine:3.24.2
script:
- echo "hello from GitLab CI"
- cat /etc/alpine-release
The pipeline started on push and passed. The job page reported 4 seconds of run time after 1 second in the queue. With the constrained settings, the API reported 2.93 seconds for a rerun.
!GitLab CE 19.5.0 job log for job 1 in root/hello-ci, status Passed. The log shows the runner pulling alpine:3.24.2, fetching the repository, running echo hello from GitLab CI and printing 3.24.2, then Job succeeded. The sidebar shows a duration of 4 seconds and runner number 1, lab-runner.
Forgejo reads workflows from .forgejo/workflows/, and falls back to .github/workflows/ when that folder is missing:
on: [push]
jobs:
hello:
runs-on: docker
steps:
- run: echo "hello from Forgejo Actions"
- run: cat /etc/alpine-release
runs-on: docker matches the label given at registration. The run passed, and the Actions page reported about 1 second for the job.
!Forgejo 15.0.10 Actions run page for labadmin/hello-ci, workflow hello.yml triggered by the add workflow commit. The hello job succeeded. Log sections show Set up job with runner version v13.2.0 starting image alpine:3.24.2 on network fj_default, then the echo step printing hello from Forgejo Actions, the cat step printing 3.24.2, and Complete job.
The syntax looks like GitHub Actions on purpose. The Forgejo Actions docs say: "Forgejo Actions is designed to be familiar to users of GitHub Actions, but it is not designed to be compatible." Expect small edits when you bring workflows over. GitLab CI uses its own YAML format, so moving between the two means a rewrite either way.
Registry, issues and code review
Both ship a container registry. On GitLab it is a separate bundled service that turns on when you set registry_external_url, here on port 5050. The requirements page lists a domain and TLS certificates for it in real use. The lab used plain HTTP on a loopback name, which Docker accepts because it treats 127.0.0.0/8 as an insecure registry by default. For any other address, Docker refuses plain HTTP unless the registry is listed under insecure-registries, so put both registries behind HTTPS before you push from another machine. On Forgejo, the registry is part of the package registry and lives on the main web port, so the image name is 127.0.0.1:3000/labadmin/alpine-copy:3.24.2. Both pushes of the same Alpine image succeeded with the same digest, sha256:7cefa58b..., after a docker login with an access token.
Issues and code review worked the same way on both. An issue, a branch, a commit and a pull or merge request with Closes #1 took a handful of API calls. Both linked the issue and showed the passing pipeline on the review page.
!GitLab CE merge request Tweak README, number 1, open from branch readme-tweak into main. The description says Closes number 1, a Pipeline number 3 passed widget shows a green check, approval is optional, and Ready to merge offers a Merge button with Delete source branch checked. The sidebar lists Lab issue: check CI badge under work items it closes.
GitLab wraps that core in more DevOps menus. The CE project sidebar in the screenshots groups them under Plan, Code, Build, Secure, Deploy, Operate, Monitor and Analyze. Forgejo keeps the GitHub-style layout: issues, pull requests, projects, packages, wiki and Actions as tabs on the repository.
!Forgejo 15.0.10 pull request Tweak README number 2 in labadmin/hello-ci, open, wanting to merge 1 commit from readme-tweak into main. The comment says Closes number 1. A checks box reads All checks were successful with hello push successful in 1s, the pull request can be merged automatically, and a Create merge commit button is shown.
Migrations, upgrades and backups
Migrating a real repository
Both instances imported https://github.com/plausible/community-edition over HTTPS as plain Git. Forgejo returned from POST /api/v1/repos/migrate in 0.4 seconds with 31 commits and 11,425 KB on disk.
GitLab 19.5.0 refused at first. On a fresh install, import_sources was an empty list, so import by URL was off and the API returned 403. Enabling git and github under Admin, Settings, General, Import and export settings fixed it, but the same request kept returning 403 for about three minutes before it succeeded. After that, the import finished within a few seconds, with the same 31 commits and 11.4 MB.
For a move away from GitLab, the Forgejo migrator can also bring issues, labels, milestones, releases and merge requests when you give it a GitLab access token. The Codeberg migration docs list GitLab among the services with metadata support. Forgejo uses one number space for issues and pull requests, so merge request numbers shift on the way in. Forgejo pull request #1790 offsets them by the highest issue number, and pull request #10077, merged in November 2025, rewrites !N references in comments to match.
Upgrades and backups
GitLab ships a minor release every month on the third Thursday, patch releases twice a month and one major release a year, per its maintenance policy. Security fixes reach the current release and the two before it. Jumps between versions must pass through required upgrade stops. For GitLab 19 those are 19.2, 19.5, 19.8 and 19.11, so the tested 19.5.0 is itself a stop. The Docker upgrade docs say to back up at least the database and the secrets file, upgrade the runner to the same version, change the image tag, then run docker compose pull and docker compose up -d. The upgrade path page says to let background migrations finish before the next stop.
Forgejo releases quarterly, with one LTS a year. The release schedule gives 15.0 LTS support until July 15, 2027, while 16.0 reaches end of life on October 29, 2026. The FAQ says major upgrades need a manual check, which is why the project publishes no latest tag. Inside a line, the 15 and 15.0 tags follow the newest minor and patch release.
For backups, GitLab: docker compose exec gitlab gitlab-backup create ran in 24.6 seconds and wrote a 16.2 MB tar to /var/opt/gitlab/backups/. It ended with a warning that gitlab.rb and gitlab-secrets.json are not in the archive. The warning adds: "You will need these files to restore a backup." Copy both separately.
Forgejo: docker compose exec -u git -w /tmp server forgejo dump --type tar.gz --file /tmp/forgejo-dump.tar.gz ran in 0.4 seconds and wrote a 15.6 MB archive with the repositories, packages, forgejo-db.sql and app.ini. That app.ini holds SECRET_KEY, INTERNAL_TOKEN and JWT_SECRET, so treat the dump file as a secret. Copy either archive off the box and test a restore on a scratch instance.
Licensing, federation and which to pick
Licensing and federation
The GitLab LICENSE file puts the code outside the ee/ directory under the MIT Expat license, with the docs under CC BY-SA 4.0. Code in ee/ falls under the EE license, which allows production use only with a GitLab subscription. The gitlab-ce image is the MIT edition, and the 19.5.0 API reported "enterprise":false.
The Forgejo FAQ says versions 9.0 and later are GPL v3 or later, versions up to 8.0 were MIT, and the project is not AGPL.
Federation through ForgeFed is a stated Forgejo goal. The FAQ says: "Federation is under active development, and is considered experimental." It warns of breaking changes that can burn the domain for future federation. The config cheat sheet defaults [federation] ENABLED to false, and the ActivityPub user endpoint returned 404 on the 17.0.0 lab install. Federated stars were the first user-facing feature, per a FOSDEM 2025 talk. Treat federation as a future feature, not a reason to switch today.
Which one to pick
Pick Forgejo when:
- The server is a small VPS, a homelab box or anything with 1 to 4 GB of RAM. The whole stack idled under 300 MiB here.
- You want GitHub-style repos, pull requests, packages and Actions with little upkeep, and a quarterly release rhythm with a yearly LTS.
- You prefer a copyleft license and a community project whose domains are held by the non-profit Codeberg e.V.
Pick GitLab CE when:
- You want one integrated DevOps tool: mature CI/CD with stages, a merge request flow and planning in the same UI.
- You can give it 8 GB of RAM or more, or accept the constrained profile and its trade-offs at about 2.4 GiB.
- You may want paid EE features later. The memory-constrained docs page says you can always upgrade from CE to EE.
For a solo developer or a small team, Forgejo covers the same daily Git work. On this host it used about a thirtieth of the idle RAM of default GitLab. Add an Uptime Kuma monitor on the login page either way, and if you want push-to-deploy on top, connect the repo to Coolify.
FAQ
Resources and CI
Why does GitLab use so much RAM when idle?
It runs many services in one container, and on this 8 vCPU host the ten Ruby processes held about 1 GB each. A default 19.5.0 container idled at 8,657.7 MiB (about 8.5 GiB) here, close to the 8.452 GB that a user reported on a fresh container in the GitLab forum thread on high RAM usage. The memory-constrained settings brought it to about 2.4 GiB.
Can GitLab run on a 4 GB VPS?
It can start with the constrained profile, which idled at 2,466.5 MiB in this run. GitLab documents 8 GB as the floor for constrained single-node installs and 16 GB as the baseline. In a forum thread about memory after the 17.0 upgrade, a reply calls 4 GB the absolute minimum and recommends 8 GB.
Can Forgejo Actions run GitHub Actions workflows unchanged?
Not always. The Forgejo docs say Actions is designed to be familiar to GitHub users but not designed to be compatible, so expect small edits. Forgejo also falls back to .github/workflows/ when .forgejo/workflows/ is missing.
Migration, licensing and federation
Can you migrate from GitLab to Forgejo with issues and merge requests?
Yes, with a GitLab access token in the migration form. Merge requests become pull requests numbered after the highest issue number, and !N references in comments are rewritten since pull request #10077. Cross-repository references are still tracked in issue #11332.
Is Forgejo licensed under the AGPL?
No. The Forgejo FAQ says GPL v3 or later from version 9.0, MIT up to 8.0, and that switching to AGPL or EUPL was proposed without agreement.
Can Forgejo instances federate with each other yet?
Only experimentally. Federation is off by default, the FAQ calls it experimental, and moderation and access control are still missing. The 2025 FOSDEM talk marked federated stars as done and listed following, comments and pull requests across instances as roadmap items.
Originally published on DeployLark: GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured
1 comment
Self-Host Plausible Community Edition with Docker
Plausible Community Edition is a self-hosted, cookie-free analytics stack. The same product family powers Plausible Cloud, and CE is the free, AGPL-licensed release you run yourself. On October 18, 2026, the analytics repository had about 29,500 stars, and the community-edition compose repo had about 2,900. This guide installs CE v3.2.1 from the official Compose files, pins every image tag, puts the dashboard on 127.0.0.1, and confirms a real pageview on a lab site.
The Umami Docker and PostgreSQL guide covers a lighter Postgres-only stack. Plausible CE adds ClickHouse for events and ships a different feature set from Cloud. For a broader app platform on the same VPS, see the Coolify install guide.
TL;DR:
- Clone plausible/community-edition at tag v3.2.1, set BASE_URL and a 64-byte SECRET_KEY_BASE in .env, and pin Postgres and ClickHouse to exact tags in compose.override.yml.
- Bind the app to 127.0.0.1:8000 and put Nginx Proxy Manager or Caddy in front with HTTPS. Keep BASE_URL equal to the public URL.
- Open the dashboard, create the first account, add a site, paste the generated script into the site <head>, and confirm a pageview on the dashboard.
- Expect about 2 GB of RAM for a calm ClickHouse setup. CPU needs SSE 4.2 or NEON.
- Upgrade by checking out a newer CE tag and running docker compose up -d. Back up Postgres with pg_dump and keep the ClickHouse volumes.
Table of contents
What CE gives you, and what stays on Cloud
Feature and licence differences
The self-hosting docs compare Cloud and CE in one table. The points that matter for a home lab or a small team are:
- You own the server: installs, upgrades, backups, capacity and uptime are yours.
- CE releases twice a year. Cloud ships features continuously.
- Marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API are Cloud-only.
- Bot filtering on CE is the basic User-Agent and referrer-spam path. Cloud adds data-center IP filtering and a broader bot model.
- Premium human support is Cloud-only. Self-hosting is community-supported only, per the self-hosting docs.
- CE is AGPL-3.0 (or later). Cloud is a paid subscription that funds the project.
If you modify CE and offer it as a network service, AGPL requires you to share the corresponding source with users of that service. Read the licence before you ship a public SaaS on top of CE.
Hardware and CPU requirements
The CE README asks for Docker, Docker Compose, a CPU with SSE 4.2 or NEON (ClickHouse needs it), and at least 2 GB of RAM so ClickHouse and Plausible stay clear of OOM kills. The compose file mounts low-resource ClickHouse settings for small servers. On the lab host used for this guide (8 vCPUs with SSE 4.2, Debian 13), the three containers used about 710 MB of RAM after the first pageviews: about 450 MB for Plausible, 185 MB for ClickHouse and 80 MB for Postgres.
Install the official Compose stack
Clone the repo and set .env
The install path is the community-edition repository, not a hand-rolled compose file. Clone a specific tag so the ClickHouse config files match the app:
git clone -b v3.2.1 --single-branch https://github.com/plausible/community-edition plausible-ce
cd plausible-ce
Create .env with the two required variables from the configuration wiki:
touch .env
chmod 600 .env
echo "BASE_URL=https://plausible.example.com" >> .env
echo "SECRET_KEY_BASE=$(openssl rand -base64 48)" >> .env
echo "HTTP_PORT=8000" >> .env
BASE_URL has no default. It drives link generation and WebSocket origin checks. Set it to the URL people will type in the browser, including https:// once a reverse proxy terminates TLS. For a local evaluation, the README tip is BASE_URL=http://localhost:8000 (or any free port). The lab run used http://127.0.0.1:8000.
SECRET_KEY_BASE also has no default. It must be at least 64 bytes. openssl rand -base64 48 produces a 64-character value that meets that rule. Runtime config raises if the key is missing: SECRET_KEY_BASE configuration option is required. A short key fails later with cookie store expects conn.secret_key_base to be at least 64 bytes, which matches discussion #3516 and issue #5612.
Pin image tags and bind to localhost
Upstream compose.yml at v3.2.1 uses ghcr.io/plausible/community-edition:v3.2.1, postgres:16-alpine and clickhouse/clickhouse-server:24.12-alpine. The last two float. On October 18, 2026, those tags resolved to PostgreSQL 16.15 and ClickHouse 24.12.6.70. Put the exact tags in compose.override.yml so future docker compose pull calls stay on known builds, and publish the app only on the loopback address as the reverse-proxy wiki shows:
services:
plausible_db:
image: postgres:16.15-alpine
plausible_events_db:
image: clickhouse/clickhouse-server:24.12.6.70-alpine
plausible:
image: ghcr.io/plausible/community-edition:v3.2.1
ports:
- "127.0.0.1:8000:8000"
The override file is the supported place for local changes. It survives git pull of a new CE tag better than editing compose.yml.
The stack looks like this once it is up:
!Diagram of the Plausible Community Edition Docker Compose stack. A visitor browser sends POST /api/event to the plausible container (CE v3.2.1 on 127.0.0.1:8000). A second browser reaches the dashboard over HTTP. The plausible container uses plausible_db (PostgreSQL 16.15) and plausible_events_db (ClickHouse 24.12.6.70). An optional reverse proxy (Caddy or Nginx Proxy Manager) terminates TLS. Named volumes are db-data, event-data, event-logs and plausible-data.
Start it:
docker compose up -d
docker compose ps
curl --head http://127.0.0.1:8000
On a warm host with images already pulled, Postgres and ClickHouse became healthy in under a minute, and /register returned HTTP 200 about 15 seconds after the app container started. An Uptime Kuma HTTP monitor on /login is enough to notice when the dashboard stops answering. The first visit is the registration page:
!Plausible Community Edition registration page titled Register your Plausible CE account, with a five-step progress bar starting at Register, and a form for full name, email, password and confirm password, plus a Create my account button. Password hint says Min 12 characters.
Create that first account right away. Default registration is invite-only after the first user (DISABLE_REGISTRATION defaults to invite_only in the wiki). The first account owns the instance.
Default database credentials in the compose file are postgres / postgres. Change them for anything exposed beyond a private lab by setting POSTGRES_PASSWORD and a matching DATABASE_URL in .env (see the configuration wiki).
Put HTTPS in front
Reverse proxy layout
From CE 2.1.2 onward, Plausible can request Let's Encrypt certificates itself when HTTP_PORT=80 and HTTPS_PORT=443 are published. Behind an existing proxy, keep HTTP_PORT=8000, bind 127.0.0.1:8000, and terminate TLS on the proxy. That is the layout this guide tested.
A minimal Caddy config from the wiki is:
plausible.example.com {
reverse_proxy localhost:8000
}
Nginx Proxy Manager works the same way: proxy host to http://127.0.0.1:8000, force SSL, and enable WebSockets so /live/websocket stays up for the live dashboard. The same 127.0.0.1 binding pattern shows up in the Vaultwarden HTTPS guide and the Paperless-ngx Compose guide. The wiki's Nginx sample proxies that path with Upgrade and Connection headers.
Keep BASE_URL on the public hostname
Whatever you pick, BASE_URL must match the public URL. With a wrong host, the dashboard HTML can load while the LiveView WebSocket returns 403. Issue #205 shows the log line Could not check origin for Phoenix.Socket transport when the browser origin and BASE_URL disagree. After you change .env, run docker compose up -d so the container recreates. A plain docker compose restart does not reload env vars.
Add a site and confirm a pageview
Install the tracking script
After registration, the wizard asks for a domain and timezone, then shows a site-specific script. Paste it into the <head> of the site. On CE v3.2.1 the snippet looks like this shape (the pa-… filename is unique per site):
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.example.com/js/pa-xxxxxxxx.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
!Plausible CE Install Plausible step showing the Script tab, a COPY box with the tracking snippet that loads a pa- script from the instance URL, optional measurements for outbound links, file downloads and form submissions, and a Verify Script installation button.
Optional checkboxes turn on outbound-link, file-download and form-submission measurements. The new script updates those choices from the dashboard. Legacy snippets that used data-domain still work. The script update guide covers migration.
The script's event endpoint is taken from BASE_URL (it posts to {BASE_URL}/api/event). Sites that only allowlist their own analytics host in Content-Security-Policy need that origin in connect-src, or an endpoint override in plausible.init as shown in discussion #6126.
Confirm the first pageview
Open the site in a normal browser and watch the CE dashboard for the domain. The lab run added example.test, served a static page with the generated snippet, and recorded pageviews. After four accepted events, the dashboard showed 4 unique visitors, 4 total pageviews and the / path under Top pages:
!Plausible CE dashboard for example.test showing 4 current visitors, Unique visitors 4, Total visits 4, Total pageviews 4, Views per visit 1, Bounce rate 100 percent, a today graph with a spike to 4, Top sources Direct / None, and Top pages /.
Two checks that catch false starts:
1. The script ignores localhost and 127.0.0.1 by design. The browser console prints Ignoring Event: localhost. That matches community-edition issue #92 and the troubleshooting docs. Test on a real hostname (a /etc/hosts entry is enough in a lab).
2. A 202 from POST /api/event is not always a stored event. The troubleshooting docs say to look for x-plausible-dropped: 1 when a visit is filtered. The lab posts that used a normal browser User-Agent and an X-Forwarded-For address returned 202 without that header, and ClickHouse held four rows in plausible_events_db.events_v2.
Upgrades and backups
Upgrades
The upgrade wiki recommends pinning a full patch tag such as v3.2.1. Security fixes are not backported, so plan to move forward. v3.2.1 itself is a security release that removes an exposed /storybook endpoint (CVE-2026-8467). General steps for a patch or minor bump:
cd plausible-ce
git fetch origin v3.2.1:v3.2.1
git checkout v3.2.1
# keep your .env and compose.override.yml
docker compose up -d
Read the release notes before every major bump. Some releases change ClickHouse config files in the community-edition repo, which is why the checkout step matters.
Backups
Postgres holds users and site settings. ClickHouse holds events. The Upgrade PostgreSQL wiki shows a pg_dump path that doubles as a backup:
docker compose stop plausible
docker compose exec plausible_db sh -c "pg_dump -U postgres plausible_db > plausible_db.bak"
docker compose cp plausible_db:plausible_db.bak ./plausible_db.bak
docker compose start plausible
Keep copies of the ClickHouse volumes (event-data, and event-logs if you care about server logs) with your volume backup tool. There is no separate CE wiki page that ships a one-line ClickHouse dump. Restoring across major ClickHouse versions needs extra care, so keep the image tag pin in compose.override.yml.
Also back up .env. Losing SECRET_KEY_BASE logs everyone out of the dashboard. Site data in Postgres and ClickHouse stays, as ruslandoga noted when rotating the key.
CE can also send account activation, password resets and optional reports when SMTP or a mail provider adapter is configured. The configuration wiki lists SMTP_HOST_ADDR, MAILER_EMAIL and the Postmark, Mailgun, Mandrill and SendGrid variables. Without SMTP, local accounts still work when ENABLE_EMAIL_VERIFICATION stays at its default false. Those providers were not exercised in the lab run.
FAQ
Setup and resources
How much RAM does Plausible CE need?
The CE README recommends at least 2 GB so ClickHouse and Plausible avoid OOM kills. The lab stack with the bundled low-resource ClickHouse config used about 710 MB of RAM after the first pageviews. Builds and heavy queries need more headroom than idle stats.
Why does the dashboard load but live data stay empty?
BASE_URL usually does not match the URL in the browser. The HTML can still load while /live/websocket fails origin checks. Set BASE_URL to the public URL, then recreate the container with docker compose up -d. See issue #205.
Why is SECRET_KEY_BASE required, and how long must it be?
The configuration wiki requires it and asks for at least 64 bytes. Generate one with openssl rand -base64 48. A missing key stops startup. A short key breaks cookie sessions with cookie store expects conn.secret_key_base to be at least 64 bytes.
Tracking and HTTPS
Why does the browser say "Ignoring Event: localhost"?
The tracker refuses localhost and loopback hosts so local development noise stays out of production stats. Use a real hostname for the test site, or a hosts-file entry that is not localhost. Maintainer replies in issue #92 call out the same console warning.
Which Cloud features are missing from CE?
Per the self-hosting docs: marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API. Bot filtering is also simpler on CE, and there is no premium support channel.
How do you put HTTPS in front of CE?
Either publish ports 80 and 443 and let CE request certificates, or bind 127.0.0.1:8000 and terminate TLS on Caddy or Nginx Proxy Manager. Keep BASE_URL on the https:// URL in both cases. The reverse-proxy wiki has sample configs.
Originally published on DeployLark: Self-Host Plausible Community Edition with Docker
1 reaction · 1 comment
Mentions
GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured GitLab CE and Forgejo both give you a private Git server with issues, code review, CI and a container registry. They sit at opposite ends of the weight scale. This comparison ran both under Docker Compose on the same box, pushed a real pipeline through each runner, pushed an image to each registry, migrated the same GitHub repository into both and ran each backup command. The numbers below come from that run on October 19, 2026. TL;DR: - On an 8 vCPU host, Forgejo 15.0.10 with Postgres and a runner idled at 285.7 MiB of RAM and under 1% of one CPU core. GitLab CE 19.5.0 with its runner idled at 8,722.6 MiB with default settings. The GitLab container alone dropped to 2,466.5 MiB with the memory-constrained settings from the GitLab docs. - Forgejo answered its health check 5.4 seconds after docker compose up -d. GitLab served its sign-in page after 145 seconds. - Both ran a trivial CI job, accepted a docker push to the built-in registry, migrated a GitHub repo and wrote a backup archive of 15.6 MB (Forgejo) or 16.2 MB (GitLab). - GitLab CE is MIT licensed, with paid features under a separate EE license. Forgejo is GPL v3 or later. Forgejo federation is experimental and off by default. - Pick Forgejo for a small team or a homelab VPS. Pick GitLab CE when you want its integrated CI/CD and project management and can give it 8 GB or more of RAM. Table of contents The measured numbers Idle RAM and CPU Each stack ran alone on the same 8 vCPU host. The numbers are idle averages from docker stats, taken after each stack had run a pipeline, a registry push, a migration and a backup. 100% CPU means one full core. !Bar chart of idle memory on the same 8 vCPU host. Forgejo 15.0.10 stack with server, Postgres and runner: 285.7 MiB. GitLab CE 19.5.0 with memory-constrained settings, GitLab container only: 2,466.5 MiB. GitLab CE 19.5.0 with default settings plus runner: 8,722.6 MiB. | Stack | Avg RAM | Peak RAM | Avg CPU | Peak CPU | | --- | --- | --- | --- | --- | | Forgejo 15.0.10 + Postgres 17.11 + runner 13.2.0 | 285.7 MiB | 300.1 MiB | 0.57% | 0.89% | | GitLab CE 19.5.0 default + runner 19.5.0 | 8,722.6 MiB | 8,797.6 MiB | 16.50% | 87.05% | | GitLab CE 19.5.0 memory-constrained (container only) | 2,466.5 MiB | 2,537.5 MiB | 9.29% | 74.12% | The constrained row leaves out the runner, so add about 65 MiB to compare it with the others. GitLab's default memory also depends on the server. Per the GitLab Puma docs, it starts one Puma worker per CPU, limited by RAM at about 1 GB each, so a 2 vCPU server starts fewer workers than this 8 vCPU host did. Inside Forgejo, the server process took about 204 MiB, Postgres about 73 MiB and the idle runner about 8 MiB. GitLab is one container with many services. gitlab-ctl status listed 16 of them, including Puma, Sidekiq, Gitaly, PostgreSQL, Redis, Prometheus, Alertmanager, the registry and several exporters. With default settings, ps inside the container showed ten Ruby processes at roughly 1 GB of resident memory each, and the cgroup reported 8,022 MiB of anonymous memory. The GitLab runner added about 65 MiB. The default GitLab number is in line with what people report on the GitLab forum. In a 2018 thread, one user who had just installed the gitlab-ce container with a single account and no code saw 8.452 GB in docker stats. The installation requirements set 16 GB and 8 vCPU as the single-node baseline, and at least 8 GB for memory-constrained setups. The memory-constrained profile comes from the GitLab docs page for small environments, which states that "With some adjustments, GitLab can run comfortably on much lower specifications than described in minimum requirements or the reference architectures." The lab applied the page's Puma, Sidekiq, Prometheus and jemalloc settings, plus the Gitaly spawn limit, but not the Gitaly per-repo concurrency block. On this host that cut the GitLab container's idle RAM by about 72%, and a pipeline still ran green. The same page asks for at least 2 GB of RAM plus 1 GB of swap and warns that functionality and performance can degrade. Forgejo 17.0.0, released October 15, 2026, idled at 315.3 MiB on the same host. As of October 19, 2026, the 17.0.0 release page advises against upgrading because of an issue that "prevents pushing, merging, or other code operations in some cases," so the rest of this guide uses the 15.0 LTS line. Install time and steps | | Forgejo 15.0.10 | GitLab CE 19.5.0 | | --- | --- | --- | | Images | forgejo 188 MB, postgres 297 MB, runner 46 MB | gitlab-ce 4.07 GB, gitlab-runner 245 MB | | Pull time on this host | 3 to 7 s per image | 53 s for gitlab-ce | | up -d to ready | 5.4 s (/api/healthz passes) | 145 s (/users/sign_in returns 200) | | Admin account | forgejo admin user create | GITLAB_ROOT_PASSWORD on first boot | | Runner setup | generate token, register, restart runner | create runner in UI or API, register | Image sizes are the local uncompressed sizes from docker images. GitLab also pulled its helper image, gitlab-runner-helper:x86_64-v19.5.0 (112 MB), on the first job. Switching GitLab to the constrained settings and recreating the container took another 98 seconds before the sign-in page came back. Install both with Docker Compose Both stacks below publish ports on 127.0.0.1 only. Put a TLS proxy such as Nginx Proxy Manager in front before anyone else uses them, the same pattern as the Plausible CE guide. Forgejo with Postgres and a runner The Forgejo Docker docs start from a single server service and add Postgres as an option. The file below adds Postgres, skips the web installer with INSTALL_LOCK, and adds a runner that waits until it has been registered: name: fj services: server: image: codeberg.org/forgejo/forgejo:15.0.10 environment: - USER_UID=1000 - USER_GID=1000 - FORGEJO__database__DB_TYPE=postgres - FORGEJO__database__HOST=db:5432 - FORGEJO__database__NAME=forgejo - FORGEJO__database__USER=forgejo - FORGEJO__database__PASSWD=change-me-db-password - FORGEJO__security__INSTALL_LOCK=true - FORGEJO__server__ROOT_URL=http://127.0.0.1:3000/ - FORGEJO__server__SSH_PORT=2222 - FORGEJO__actions__ENABLED=true - FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org restart: unless-stopped volumes: - fj-data:/data ports: - "127.0.0.1:3000:3000" - "127.0.0.1:2222:22" depends_on: - db db: image: postgres:17.11-alpine restart: unless-stopped environment: - POSTGRES_USER=forgejo - POSTGRES_PASSWORD=change-me-db-password - POSTGRES_DB=forgejo volumes: - fj-pg:/var/lib/postgresql/data runner: image: code.forgejo.org/forgejo/runner:13.2.0 user: "0:0" restart: unless-stopped command: ["/bin/sh", "-c", "while [ ! -f /data/.runner ]; do sleep 2; done; exec forgejo-runner daemon --config /data/config.yml"] volumes: - fj-runner:/data - /var/run/docker.sock:/var/run/docker.sock depends_on: - server volumes: fj-data: fj-pg: fj-runner: FORGEJO__actions__ENABLED=true and the default actions URL match the documented defaults and are written out so the file explains itself. Forgejo publishes no latest tag at all, by design, so pinning a full version like 15.0.10 is the normal path. Start it, create an admin and register the runner: docker compose up -d docker compose exec -u git server forgejo admin user create --admin \ --username labadmin --email you@example.com --password "$(openssl rand -hex 12)" \ --must-change-password=false TOKEN=$(docker compose exec -u git server forgejo actions generate-runner-token | tr -d "\r") docker compose exec runner sh -c "cd /data && forgejo-runner generate-config > config.yml \ && sed -i \"s/^ network: .*/ network: fj_default/\" config.yml \ && forgejo-runner register --no-interactive --instance http://server:3000 \ --token $TOKEN --name lab-runner --labels docker:docker://alpine:3.24.2" docker compose restart runner The network: fj_default line puts job containers on the Compose network so they can reach http://server:3000. The runner mounts the Docker socket, which gives it root-level control of the host. Run it on a separate machine if untrusted people can push workflows. GitLab CE with a runner The GitLab Docker install docs ask for an externally reachable hostname and say not to use localhost. The lab mapped gitlab.lab.test to 127.0.0.1 in /etc/hosts and gave the container the same name as a network alias, so the runner and job containers resolve it too: name: gl services: gitlab: image: gitlab/gitlab-ce:19.5.0-ce.0 hostname: gitlab.lab.test restart: unless-stopped shm_size: "256m" environment: GITLAB_ROOT_PASSWORD: ${GITLAB_ROOT_PASSWORD} GITLAB_OMNIBUS_CONFIG: | external_url "http://gitlab.lab.test:8929" registry_external_url "http://gitlab.lab.test:5050" gitlab_rails["gitlab_shell_ssh_port"] = 2224 ports: - "127.0.0.1:8929:8929" - "127.0.0.1:5050:5050" - "127.0.0.1:2224:22" volumes: - gl-config:/etc/gitlab - gl-logs:/var/log/gitlab - gl-data:/var/opt/gitlab networks: default: aliases: - gitlab.lab.test runner: image: gitlab/gitlab-runner:alpine-v19.5.0 restart: unless-stopped volumes: - gl-runner:/etc/gitlab-runner - /var/run/docker.sock:/var/run/docker.sock volumes: gl-config: gl-logs: gl-data: gl-runner: Put GITLAB_ROOT_PASSWORD= with a long random value in a .env file next to it and run docker compose up -d. Without that variable, GitLab writes a generated password to /etc/gitlab/initial_root_password inside the container and deletes it on the first container restart after 24 hours. For the constrained run, these lines were appended to GITLAB_OMNIBUS_CONFIG and the container recreated: # memory-constrained settings from docs.gitlab.com/omnibus/settings/memory_constrained_envs puma["worker_processes"] = 0 sidekiq["concurrency"] = 10 prometheus_monitoring["enable"] = false gitlab_rails["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000" } gitaly["env"] = { "MALLOC_CONF" => "dirty_decay_ms:1000,muzzy_decay_ms:1000", "GITALY_COMMAND_SPAWN_MAX_PARALLEL" => "2" } Then create an instance runner under Admin, CI/CD, Runners (or with POST /api/v4/user/runners), copy the glrt- token and register: docker compose exec runner gitlab-runner register --non-interactive \ --url http://gitlab.lab.test:8929 --token glrt-REPLACE_ME \ --executor docker --docker-image alpine:3.24.2 --docker-network-mode gl_default CI, registry, issues and merge requests GitLab CI and Forgejo Actions Each instance got a hello-ci repository with the same two-step job on alpine:3.24.2. The GitLab version lives in .gitlab-ci.yml: hello: image: alpine:3.24.2 script: - echo "hello from GitLab CI" - cat /etc/alpine-release The pipeline started on push and passed. The job page reported 4 seconds of run time after 1 second in the queue. With the constrained settings, the API reported 2.93 seconds for a rerun. !GitLab CE 19.5.0 job log for job 1 in root/hello-ci, status Passed. The log shows the runner pulling alpine:3.24.2, fetching the repository, running echo hello from GitLab CI and printing 3.24.2, then Job succeeded. The sidebar shows a duration of 4 seconds and runner number 1, lab-runner. Forgejo reads workflows from .forgejo/workflows/, and falls back to .github/workflows/ when that folder is missing: on: [push] jobs: hello: runs-on: docker steps: - run: echo "hello from Forgejo Actions" - run: cat /etc/alpine-release runs-on: docker matches the label given at registration. The run passed, and the Actions page reported about 1 second for the job. !Forgejo 15.0.10 Actions run page for labadmin/hello-ci, workflow hello.yml triggered by the add workflow commit. The hello job succeeded. Log sections show Set up job with runner version v13.2.0 starting image alpine:3.24.2 on network fj_default, then the echo step printing hello from Forgejo Actions, the cat step printing 3.24.2, and Complete job. The syntax looks like GitHub Actions on purpose. The Forgejo Actions docs say: "Forgejo Actions is designed to be familiar to users of GitHub Actions, but it is not designed to be compatible." Expect small edits when you bring workflows over. GitLab CI uses its own YAML format, so moving between the two means a rewrite either way. Registry, issues and code review Both ship a container registry. On GitLab it is a separate bundled service that turns on when you set registry_external_url, here on port 5050. The requirements page lists a domain and TLS certificates for it in real use. The lab used plain HTTP on a loopback name, which Docker accepts because it treats 127.0.0.0/8 as an insecure registry by default. For any other address, Docker refuses plain HTTP unless the registry is listed under insecure-registries, so put both registries behind HTTPS before you push from another machine. On Forgejo, the registry is part of the package registry and lives on the main web port, so the image name is 127.0.0.1:3000/labadmin/alpine-copy:3.24.2. Both pushes of the same Alpine image succeeded with the same digest, sha256:7cefa58b..., after a docker login with an access token. Issues and code review worked the same way on both. An issue, a branch, a commit and a pull or merge request with Closes #1 took a handful of API calls. Both linked the issue and showed the passing pipeline on the review page. !GitLab CE merge request Tweak README, number 1, open from branch readme-tweak into main. The description says Closes number 1, a Pipeline number 3 passed widget shows a green check, approval is optional, and Ready to merge offers a Merge button with Delete source branch checked. The sidebar lists Lab issue: check CI badge under work items it closes. GitLab wraps that core in more DevOps menus. The CE project sidebar in the screenshots groups them under Plan, Code, Build, Secure, Deploy, Operate, Monitor and Analyze. Forgejo keeps the GitHub-style layout: issues, pull requests, projects, packages, wiki and Actions as tabs on the repository. !Forgejo 15.0.10 pull request Tweak README number 2 in labadmin/hello-ci, open, wanting to merge 1 commit from readme-tweak into main. The comment says Closes number 1. A checks box reads All checks were successful with hello push successful in 1s, the pull request can be merged automatically, and a Create merge commit button is shown. Migrations, upgrades and backups Migrating a real repository Both instances imported https://github.com/plausible/community-edition over HTTPS as plain Git. Forgejo returned from POST /api/v1/repos/migrate in 0.4 seconds with 31 commits and 11,425 KB on disk. GitLab 19.5.0 refused at first. On a fresh install, import_sources was an empty list, so import by URL was off and the API returned 403. Enabling git and github under Admin, Settings, General, Import and export settings fixed it, but the same request kept returning 403 for about three minutes before it succeeded. After that, the import finished within a few seconds, with the same 31 commits and 11.4 MB. For a move away from GitLab, the Forgejo migrator can also bring issues, labels, milestones, releases and merge requests when you give it a GitLab access token. The Codeberg migration docs list GitLab among the services with metadata support. Forgejo uses one number space for issues and pull requests, so merge request numbers shift on the way in. Forgejo pull request #1790 offsets them by the highest issue number, and pull request #10077, merged in November 2025, rewrites !N references in comments to match. Upgrades and backups GitLab ships a minor release every month on the third Thursday, patch releases twice a month and one major release a year, per its maintenance policy. Security fixes reach the current release and the two before it. Jumps between versions must pass through required upgrade stops. For GitLab 19 those are 19.2, 19.5, 19.8 and 19.11, so the tested 19.5.0 is itself a stop. The Docker upgrade docs say to back up at least the database and the secrets file, upgrade the runner to the same version, change the image tag, then run docker compose pull and docker compose up -d. The upgrade path page says to let background migrations finish before the next stop. Forgejo releases quarterly, with one LTS a year. The release schedule gives 15.0 LTS support until July 15, 2027, while 16.0 reaches end of life on October 29, 2026. The FAQ says major upgrades need a manual check, which is why the project publishes no latest tag. Inside a line, the 15 and 15.0 tags follow the newest minor and patch release. For backups, GitLab: docker compose exec gitlab gitlab-backup create ran in 24.6 seconds and wrote a 16.2 MB tar to /var/opt/gitlab/backups/. It ended with a warning that gitlab.rb and gitlab-secrets.json are not in the archive. The warning adds: "You will need these files to restore a backup." Copy both separately. Forgejo: docker compose exec -u git -w /tmp server forgejo dump --type tar.gz --file /tmp/forgejo-dump.tar.gz ran in 0.4 seconds and wrote a 15.6 MB archive with the repositories, packages, forgejo-db.sql and app.ini. That app.ini holds SECRET_KEY, INTERNAL_TOKEN and JWT_SECRET, so treat the dump file as a secret. Copy either archive off the box and test a restore on a scratch instance. Licensing, federation and which to pick Licensing and federation The GitLab LICENSE file puts the code outside the ee/ directory under the MIT Expat license, with the docs under CC BY-SA 4.0. Code in ee/ falls under the EE license, which allows production use only with a GitLab subscription. The gitlab-ce image is the MIT edition, and the 19.5.0 API reported "enterprise":false. The Forgejo FAQ says versions 9.0 and later are GPL v3 or later, versions up to 8.0 were MIT, and the project is not AGPL. Federation through ForgeFed is a stated Forgejo goal. The FAQ says: "Federation is under active development, and is considered experimental." It warns of breaking changes that can burn the domain for future federation. The config cheat sheet defaults [federation] ENABLED to false, and the ActivityPub user endpoint returned 404 on the 17.0.0 lab install. Federated stars were the first user-facing feature, per a FOSDEM 2025 talk. Treat federation as a future feature, not a reason to switch today. Which one to pick Pick Forgejo when: - The server is a small VPS, a homelab box or anything with 1 to 4 GB of RAM. The whole stack idled under 300 MiB here. - You want GitHub-style repos, pull requests, packages and Actions with little upkeep, and a quarterly release rhythm with a yearly LTS. - You prefer a copyleft license and a community project whose domains are held by the non-profit Codeberg e.V. Pick GitLab CE when: - You want one integrated DevOps tool: mature CI/CD with stages, a merge request flow and planning in the same UI. - You can give it 8 GB of RAM or more, or accept the constrained profile and its trade-offs at about 2.4 GiB. - You may want paid EE features later. The memory-constrained docs page says you can always upgrade from CE to EE. For a solo developer or a small team, Forgejo covers the same daily Git work. On this host it used about a thirtieth of the idle RAM of default GitLab. Add an Uptime Kuma monitor on the login page either way, and if you want push-to-deploy on top, connect the repo to Coolify. FAQ Resources and CI Why does GitLab use so much RAM when idle? It runs many services in one container, and on this 8 vCPU host the ten Ruby processes held about 1 GB each. A default 19.5.0 container idled at 8,657.7 MiB (about 8.5 GiB) here, close to the 8.452 GB that a user reported on a fresh container in the GitLab forum thread on high RAM usage. The memory-constrained settings brought it to about 2.4 GiB. Can GitLab run on a 4 GB VPS? It can start with the constrained profile, which idled at 2,466.5 MiB in this run. GitLab documents 8 GB as the floor for constrained single-node installs and 16 GB as the baseline. In a forum thread about memory after the 17.0 upgrade, a reply calls 4 GB the absolute minimum and recommends 8 GB. Can Forgejo Actions run GitHub Actions workflows unchanged? Not always. The Forgejo docs say Actions is designed to be familiar to GitHub users but not designed to be compatible, so expect small edits. Forgejo also falls back to .github/workflows/ when .forgejo/workflows/ is missing. Migration, licensing and federation Can you migrate from GitLab to Forgejo with issues and merge requests? Yes, with a GitLab access token in the migration form. Merge requests become pull requests numbered after the highest issue number, and !N references in comments are rewritten since pull request #10077. Cross-repository references are still tracked in issue #11332. Is Forgejo licensed under the AGPL? No. The Forgejo FAQ says GPL v3 or later from version 9.0, MIT up to 8.0, and that switching to AGPL or EUPL was proposed without agreement. Can Forgejo instances federate with each other yet? Only experimentally. Federation is off by default, the FAQ calls it experimental, and moderation and access control are still missing. The 2025 FOSDEM talk marked federated stars as done and listed following, comments and pull requests across instances as roadmap items. Originally published on DeployLark: GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured
Self-Host Plausible Community Edition with Docker Plausible Community Edition is a self-hosted, cookie-free analytics stack. The same product family powers Plausible Cloud, and CE is the free, AGPL-licensed release you run yourself. On October 18, 2026, the analytics repository had about 29,500 stars, and the community-edition compose repo had about 2,900. This guide installs CE v3.2.1 from the official Compose files, pins every image tag, puts the dashboard on 127.0.0.1, and confirms a real pageview on a lab site. The Umami Docker and PostgreSQL guide covers a lighter Postgres-only stack. Plausible CE adds ClickHouse for events and ships a different feature set from Cloud. For a broader app platform on the same VPS, see the Coolify install guide. TL;DR: - Clone plausible/community-edition at tag v3.2.1, set BASE_URL and a 64-byte SECRET_KEY_BASE in .env, and pin Postgres and ClickHouse to exact tags in compose.override.yml. - Bind the app to 127.0.0.1:8000 and put Nginx Proxy Manager or Caddy in front with HTTPS. Keep BASE_URL equal to the public URL. - Open the dashboard, create the first account, add a site, paste the generated script into the site <head>, and confirm a pageview on the dashboard. - Expect about 2 GB of RAM for a calm ClickHouse setup. CPU needs SSE 4.2 or NEON. - Upgrade by checking out a newer CE tag and running docker compose up -d. Back up Postgres with pg_dump and keep the ClickHouse volumes. Table of contents What CE gives you, and what stays on Cloud Feature and licence differences The self-hosting docs compare Cloud and CE in one table. The points that matter for a home lab or a small team are: - You own the server: installs, upgrades, backups, capacity and uptime are yours. - CE releases twice a year. Cloud ships features continuously. - Marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API are Cloud-only. - Bot filtering on CE is the basic User-Agent and referrer-spam path. Cloud adds data-center IP filtering and a broader bot model. - Premium human support is Cloud-only. Self-hosting is community-supported only, per the self-hosting docs. - CE is AGPL-3.0 (or later). Cloud is a paid subscription that funds the project. If you modify CE and offer it as a network service, AGPL requires you to share the corresponding source with users of that service. Read the licence before you ship a public SaaS on top of CE. Hardware and CPU requirements The CE README asks for Docker, Docker Compose, a CPU with SSE 4.2 or NEON (ClickHouse needs it), and at least 2 GB of RAM so ClickHouse and Plausible stay clear of OOM kills. The compose file mounts low-resource ClickHouse settings for small servers. On the lab host used for this guide (8 vCPUs with SSE 4.2, Debian 13), the three containers used about 710 MB of RAM after the first pageviews: about 450 MB for Plausible, 185 MB for ClickHouse and 80 MB for Postgres. Install the official Compose stack Clone the repo and set .env The install path is the community-edition repository, not a hand-rolled compose file. Clone a specific tag so the ClickHouse config files match the app: git clone -b v3.2.1 --single-branch https://github.com/plausible/community-edition plausible-ce cd plausible-ce Create .env with the two required variables from the configuration wiki: touch .env chmod 600 .env echo "BASE_URL=https://plausible.example.com" >> .env echo "SECRET_KEY_BASE=$(openssl rand -base64 48)" >> .env echo "HTTP_PORT=8000" >> .env BASE_URL has no default. It drives link generation and WebSocket origin checks. Set it to the URL people will type in the browser, including https:// once a reverse proxy terminates TLS. For a local evaluation, the README tip is BASE_URL=http://localhost:8000 (or any free port). The lab run used http://127.0.0.1:8000. SECRET_KEY_BASE also has no default. It must be at least 64 bytes. openssl rand -base64 48 produces a 64-character value that meets that rule. Runtime config raises if the key is missing: SECRET_KEY_BASE configuration option is required. A short key fails later with cookie store expects conn.secret_key_base to be at least 64 bytes, which matches discussion #3516 and issue #5612. Pin image tags and bind to localhost Upstream compose.yml at v3.2.1 uses ghcr.io/plausible/community-edition:v3.2.1, postgres:16-alpine and clickhouse/clickhouse-server:24.12-alpine. The last two float. On October 18, 2026, those tags resolved to PostgreSQL 16.15 and ClickHouse 24.12.6.70. Put the exact tags in compose.override.yml so future docker compose pull calls stay on known builds, and publish the app only on the loopback address as the reverse-proxy wiki shows: services: plausible_db: image: postgres:16.15-alpine plausible_events_db: image: clickhouse/clickhouse-server:24.12.6.70-alpine plausible: image: ghcr.io/plausible/community-edition:v3.2.1 ports: - "127.0.0.1:8000:8000" The override file is the supported place for local changes. It survives git pull of a new CE tag better than editing compose.yml. The stack looks like this once it is up: !Diagram of the Plausible Community Edition Docker Compose stack. A visitor browser sends POST /api/event to the plausible container (CE v3.2.1 on 127.0.0.1:8000). A second browser reaches the dashboard over HTTP. The plausible container uses plausible_db (PostgreSQL 16.15) and plausible_events_db (ClickHouse 24.12.6.70). An optional reverse proxy (Caddy or Nginx Proxy Manager) terminates TLS. Named volumes are db-data, event-data, event-logs and plausible-data. Start it: docker compose up -d docker compose ps curl --head http://127.0.0.1:8000 On a warm host with images already pulled, Postgres and ClickHouse became healthy in under a minute, and /register returned HTTP 200 about 15 seconds after the app container started. An Uptime Kuma HTTP monitor on /login is enough to notice when the dashboard stops answering. The first visit is the registration page: !Plausible Community Edition registration page titled Register your Plausible CE account, with a five-step progress bar starting at Register, and a form for full name, email, password and confirm password, plus a Create my account button. Password hint says Min 12 characters. Create that first account right away. Default registration is invite-only after the first user (DISABLE_REGISTRATION defaults to invite_only in the wiki). The first account owns the instance. Default database credentials in the compose file are postgres / postgres. Change them for anything exposed beyond a private lab by setting POSTGRES_PASSWORD and a matching DATABASE_URL in .env (see the configuration wiki). Put HTTPS in front Reverse proxy layout From CE 2.1.2 onward, Plausible can request Let's Encrypt certificates itself when HTTP_PORT=80 and HTTPS_PORT=443 are published. Behind an existing proxy, keep HTTP_PORT=8000, bind 127.0.0.1:8000, and terminate TLS on the proxy. That is the layout this guide tested. A minimal Caddy config from the wiki is: plausible.example.com { reverse_proxy localhost:8000 } Nginx Proxy Manager works the same way: proxy host to http://127.0.0.1:8000, force SSL, and enable WebSockets so /live/websocket stays up for the live dashboard. The same 127.0.0.1 binding pattern shows up in the Vaultwarden HTTPS guide and the Paperless-ngx Compose guide. The wiki's Nginx sample proxies that path with Upgrade and Connection headers. Keep BASE_URL on the public hostname Whatever you pick, BASE_URL must match the public URL. With a wrong host, the dashboard HTML can load while the LiveView WebSocket returns 403. Issue #205 shows the log line Could not check origin for Phoenix.Socket transport when the browser origin and BASE_URL disagree. After you change .env, run docker compose up -d so the container recreates. A plain docker compose restart does not reload env vars. Add a site and confirm a pageview Install the tracking script After registration, the wizard asks for a domain and timezone, then shows a site-specific script. Paste it into the <head> of the site. On CE v3.2.1 the snippet looks like this shape (the pa-… filename is unique per site): <!-- Privacy-friendly analytics by Plausible --> <script async src="https://plausible.example.com/js/pa-xxxxxxxx.js"></script> <script> window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; plausible.init() </script> !Plausible CE Install Plausible step showing the Script tab, a COPY box with the tracking snippet that loads a pa- script from the instance URL, optional measurements for outbound links, file downloads and form submissions, and a Verify Script installation button. Optional checkboxes turn on outbound-link, file-download and form-submission measurements. The new script updates those choices from the dashboard. Legacy snippets that used data-domain still work. The script update guide covers migration. The script's event endpoint is taken from BASE_URL (it posts to {BASE_URL}/api/event). Sites that only allowlist their own analytics host in Content-Security-Policy need that origin in connect-src, or an endpoint override in plausible.init as shown in discussion #6126. Confirm the first pageview Open the site in a normal browser and watch the CE dashboard for the domain. The lab run added example.test, served a static page with the generated snippet, and recorded pageviews. After four accepted events, the dashboard showed 4 unique visitors, 4 total pageviews and the / path under Top pages: !Plausible CE dashboard for example.test showing 4 current visitors, Unique visitors 4, Total visits 4, Total pageviews 4, Views per visit 1, Bounce rate 100 percent, a today graph with a spike to 4, Top sources Direct / None, and Top pages /. Two checks that catch false starts: 1. The script ignores localhost and 127.0.0.1 by design. The browser console prints Ignoring Event: localhost. That matches community-edition issue #92 and the troubleshooting docs. Test on a real hostname (a /etc/hosts entry is enough in a lab). 2. A 202 from POST /api/event is not always a stored event. The troubleshooting docs say to look for x-plausible-dropped: 1 when a visit is filtered. The lab posts that used a normal browser User-Agent and an X-Forwarded-For address returned 202 without that header, and ClickHouse held four rows in plausible_events_db.events_v2. Upgrades and backups Upgrades The upgrade wiki recommends pinning a full patch tag such as v3.2.1. Security fixes are not backported, so plan to move forward. v3.2.1 itself is a security release that removes an exposed /storybook endpoint (CVE-2026-8467). General steps for a patch or minor bump: cd plausible-ce git fetch origin v3.2.1:v3.2.1 git checkout v3.2.1 # keep your .env and compose.override.yml docker compose up -d Read the release notes before every major bump. Some releases change ClickHouse config files in the community-edition repo, which is why the checkout step matters. Backups Postgres holds users and site settings. ClickHouse holds events. The Upgrade PostgreSQL wiki shows a pg_dump path that doubles as a backup: docker compose stop plausible docker compose exec plausible_db sh -c "pg_dump -U postgres plausible_db > plausible_db.bak" docker compose cp plausible_db:plausible_db.bak ./plausible_db.bak docker compose start plausible Keep copies of the ClickHouse volumes (event-data, and event-logs if you care about server logs) with your volume backup tool. There is no separate CE wiki page that ships a one-line ClickHouse dump. Restoring across major ClickHouse versions needs extra care, so keep the image tag pin in compose.override.yml. Also back up .env. Losing SECRET_KEY_BASE logs everyone out of the dashboard. Site data in Postgres and ClickHouse stays, as ruslandoga noted when rotating the key. CE can also send account activation, password resets and optional reports when SMTP or a mail provider adapter is configured. The configuration wiki lists SMTP_HOST_ADDR, MAILER_EMAIL and the Postmark, Mailgun, Mandrill and SendGrid variables. Without SMTP, local accounts still work when ENABLE_EMAIL_VERIFICATION stays at its default false. Those providers were not exercised in the lab run. FAQ Setup and resources How much RAM does Plausible CE need? The CE README recommends at least 2 GB so ClickHouse and Plausible avoid OOM kills. The lab stack with the bundled low-resource ClickHouse config used about 710 MB of RAM after the first pageviews. Builds and heavy queries need more headroom than idle stats. Why does the dashboard load but live data stay empty? BASE_URL usually does not match the URL in the browser. The HTML can still load while /live/websocket fails origin checks. Set BASE_URL to the public URL, then recreate the container with docker compose up -d. See issue #205. Why is SECRET_KEY_BASE required, and how long must it be? The configuration wiki requires it and asks for at least 64 bytes. Generate one with openssl rand -base64 48. A missing key stops startup. A short key breaks cookie sessions with cookie store expects conn.secret_key_base to be at least 64 bytes. Tracking and HTTPS Why does the browser say "Ignoring Event: localhost"? The tracker refuses localhost and loopback hosts so local development noise stays out of production stats. Use a real hostname for the test site, or a hosts-file entry that is not localhost. Maintainer replies in issue #92 call out the same console warning. Which Cloud features are missing from CE? Per the self-hosting docs: marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API. Bot filtering is also simpler on CE, and there is no premium support channel. How do you put HTTPS in front of CE? Either publish ports 80 and 443 and let CE request certificates, or bind 127.0.0.1:8000 and terminate TLS on Caddy or Nginx Proxy Manager. Keep BASE_URL on the https:// URL in both cases. The reverse-proxy wiki has sample configs. Originally published on DeployLark: Self-Host Plausible Community Edition with Docker