-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Self Hosting: Docker Compose setup and flow #640
Conversation
@Radu-C-Martin nice start here! I think the goal for this issue will be to merge an "all in one" solution that is well tested and validated across the entire self-hosting flow, which will include:
Any thoughts on how we can test and validate amongst the community? I'd imagine we'll have quite a few different types of setups to run through. |
I'm going to mark this pull request as a draft and would encourage any other self-hosters to collaborate on this branch to get this to completion. Would love to hear more thoughts around:
|
I played around with this some more. I adjusted the deployment workflow to tag not only the complete version, but also a major.minor tag and a major-only tag (this probably would only make sense if breaking changes only happen in major versions?). I also added a step to prune the untagged images when deploying, since creating a new image for every commit will generate quite a few images :) Additionally, I updated the documentation a bit to reflect the docker deployment. From commercial VPS I could only test Linode, but it works well there. I left the references to my images in the docker-compose file for now, so that people can play around with that and suggest changes. One thing to note is that the |
Coming along nicely! Would love to hear from additional self-hosters on their own use-cases and review of this flow. |
EDIT: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good, can't wait to see this merged! just a couples questions/comments
Am I missing something, or is there not really a docker image actually available yet?
|
Not available yet, it will be available once this gets merged. This PR also includes the publish workflow 😅 If you want to check if everything works, you could use the |
Gotcha, thanks! |
@Radu-C-Martin I'll do some local testing on all of this today/tomorrow and we'll get it merged! |
README.md
Outdated
@@ -30,6 +30,10 @@ You can find [detailed setup guides for self hosting here](docs/self-hosting.md) | |||
1. Click the button above | |||
2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md) | |||
|
|||
### Manual docker deploy | |||
|
|||
A docker image, along with an example docker-compose file are provided for those who wish to deploy the application that way. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A docker image, along with an example docker-compose file are provided for those who wish to deploy the application that way. | |
To host Maybe with Docker Compose, please follow our [Docker self-hosting guide](docs/self-hosting/docker.md). |
README.md
Outdated
@@ -30,6 +30,10 @@ You can find [detailed setup guides for self hosting here](docs/self-hosting.md) | |||
1. Click the button above | |||
2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md) | |||
|
|||
### Manual docker deploy |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
### Manual docker deploy | |
### Docker |
docker-compose.example.yml
Outdated
- .env | ||
environment: | ||
DB_HOST: "postgres" | ||
HOSTING_PLATFORM: "localhost" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can remove HOSTING_PLATFORM
. The original reason for introducing this variable was in relation to auto-upgrades, but those assumptions are no longer relevant based on the iterations we've gone through here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In addition to this, we will need to hide both "auto updates" and "provider settings" in the self hosted settings since these do not apply to an app running via docker compose:
maybe/app/views/settings/hostings/show.html.erb
Lines 6 to 48 in dc024d6
<%= settings_section title: t(".general_settings_title") do %> | |
<%= form_with model: Setting.new, url: settings_hosting_path, method: :patch, local: true, html: { class: "space-y-6", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } } do |form| %> | |
<div> | |
<h2 class="font-medium mb-1"><%= t(".upgrades.title") %></h2> | |
<p class="text-gray-500 text-sm mb-4"><%= t(".upgrades.description") %></p> | |
<div class="space-y-4"> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_manual, t(".upgrades.manual.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.manual.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.manual.description") %> | |
</span> | |
<% end %> | |
</div> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_release, t(".upgrades.latest_release.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.latest_release.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.latest_release.description") %> | |
</span> | |
<% end %> | |
</div> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_commit, t(".upgrades.latest_commit.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.latest_commit.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.latest_commit.description") %> | |
</span> | |
<% end %> | |
</div> | |
</div> | |
</div> | |
<div> | |
<h2 class="font-medium mb-1"><%= t(".provider_settings.title") %></h2> | |
<p class="text-gray-500 text-sm mb-4"><%= t(".render_deploy_hook_description") %></p> | |
<%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %> | |
</div> |
docs/self-hosting.md
Outdated
**Estimated cost:** $5-15 per month | ||
|
||
_Docker is not yet supported, but will be soon._ | ||
The steps of deploying a commercial VPS instance provisioned with docker are | ||
outlined in the [docker guide](self-hosting/docker.md). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we consolidate all of this into the self-hosting/docker.md
file?
This file (docs/self-hosting.md
) could be as short as:
## Docker
**Estimated cost:** $5-15 per month
Please see the [Docker self-hosting guide](self-hosting/docker.md) for detailed setup instructions.
And then self-hosting/docker.md
would provide these sections:
# Self Hosting Maybe with Docker
## Quick Start
[Concise list of commands to run]
## Prerequisites and Setup
[Required deps for fresh VPS]
## Running the app
[Instructions for getting the app started with compose]
## Updating the App
[Explain the flow of pulling updates by tag / commit and updating instance]
## Where should I host?
### Commercial VPS
### One-Click VPS
### Standalone Image
## Troubleshooting
[We can leave mostly blank for now, but this will be a catch-all]
.github/workflows/publish.yml
Outdated
tags: ${{ steps.meta.outputs.tags }} | ||
labels: ${{ steps.meta.outputs.labels }} | ||
|
||
- name: Prune all untagged docker images |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be wrong, but I don't think Github charges anything for storage on GHCR for public repos?
Either way, I think we can remove this final step here for simplicity.
.github/workflows/publish.yml
Outdated
- name: Set up Docker Buildx | ||
uses: docker/setup-buildx-action@v3 | ||
with: | ||
endpoint: builders |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we remove these two steps? It looks like the ubuntu-latest
runner already has Docker-Buildx 0.14.0
installed:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Radu-C-Martin had some time this afternoon to thoroughly test this. I was able to get it all running on a Digital Ocean droplet, so I think the sample compose file looks good.
Just left some comments around config and documentation that I think we should address before merging this in.
.github/workflows/publish.yml
Outdated
type=semver,pattern={{major}} | ||
type=semver,pattern={{major}}.{{minor}} | ||
type=semver,pattern={{major}}.{{minor}}.{{patch}} | ||
type=ref,event=branch |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking the only two tags that we'll need to support for now are the "latest" and "release" tags. By using default configs for metadata-action
, I believe we can simplify to this:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# set latest tag for main branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
# set semver tag for versioned tags
type=semver,pattern={{version}}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my experience, the latest
tag always points to the latest release. Do you plan for the main branch to always be production-ready?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In theory (since we're using trunk-based development), yes, main
should always be production ready. If it's not, the checks should fail and the Docker image should not be published.
I would expect latest
to represent the most recently published image, which in our case, would always be the latest commit.
But there probably is some value in providing an alias for self-hosters who want to use the "latest release". What do you think of using the following tag scheme?
latest
- the latest published image (i.e.main
)stable
- the latest published release[release_semver_tag]
- the semver tag of the release[commit_sha]
- the commit sha of the docker image so self-hoster can lock into a certain version
So something like:
tags:
# Explicit commit sha and version tags
type=sha
type=semver,pattern={{version}}
# Aliases
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As long as the tags are semvar compatible, the existing tag scheme is very nice to be able to select an upgrade schedule using auto image pulls. One can have maybe:latest
which always auto upgrades (and should not be done), while one can do maybe:2
or maybe:2.3
and get the patch upgrades.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's how I've seen it done in other projects. It's intuitive, and also works well with renovate/dependabot. But I guess that would impose stricter adherence to semver versioning to be totally useful, so it's more for the core maintainers to decide. It can always be added later :D
docker-compose.example.yml
Outdated
- .env | ||
environment: | ||
DB_HOST: "postgres" | ||
HOSTING_PLATFORM: "localhost" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In addition to this, we will need to hide both "auto updates" and "provider settings" in the self hosted settings since these do not apply to an app running via docker compose:
maybe/app/views/settings/hostings/show.html.erb
Lines 6 to 48 in dc024d6
<%= settings_section title: t(".general_settings_title") do %> | |
<%= form_with model: Setting.new, url: settings_hosting_path, method: :patch, local: true, html: { class: "space-y-6", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } } do |form| %> | |
<div> | |
<h2 class="font-medium mb-1"><%= t(".upgrades.title") %></h2> | |
<p class="text-gray-500 text-sm mb-4"><%= t(".upgrades.description") %></p> | |
<div class="space-y-4"> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_manual, t(".upgrades.manual.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.manual.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.manual.description") %> | |
</span> | |
<% end %> | |
</div> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_release, t(".upgrades.latest_release.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.latest_release.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.latest_release.description") %> | |
</span> | |
<% end %> | |
</div> | |
<div class="flex items-center gap-4"> | |
<%= form.radio_button :upgrades_mode, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %> | |
<%= form.label :upgrades_mode_commit, t(".upgrades.latest_commit.title"), class: "text-gray-900 text-sm" do %> | |
<span class="font-medium"><%= t(".upgrades.latest_commit.title") %></span> | |
<br> | |
<span class="text-gray-500"> | |
<%= t(".upgrades.latest_commit.description") %> | |
</span> | |
<% end %> | |
</div> | |
</div> | |
</div> | |
<div> | |
<h2 class="font-medium mb-1"><%= t(".provider_settings.title") %></h2> | |
<p class="text-gray-500 text-sm mb-4"><%= t(".render_deploy_hook_description") %></p> | |
<%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %> | |
</div> |
docker-compose.example.yml
Outdated
app: | ||
image: ghcr.io/maybe-finance/maybe:latest | ||
ports: | ||
- 3000:3000 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this should be:
- 3000:3000 | |
- "127.0.0.1:3000:3000" |
so the Rails backend is not exposed to the internet without a reverse proxy in front of it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's needed, and even then if you want to stop it being exposed to the host (not internet, since that would require port-forwarding) just don't bind the ports and use a reverse proxy container.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
being exposed to the host (not internet, since that would require port-forwarding)
@gantoine I think your vision of how this works is incorrect, especially if you're talking about servers that are exposed to the internet directly.
3000:3000
is equivalent to 0.0.0.0:3000:3000
which means listen on port 3000 on ALL interfaces
so if your server has a network interface that's exposed to the internet (literally any VPS) then it will accept incoming connections on port 3000 from virtually anyone. That's not a safe/sane default.
just don't bind the ports and use a reverse proxy container
That's another assumption that's going to make it harder for anyone interested in self-hosting, since your reverse proxy container will listen on ports 80 and 443, and you will have to put any/all subsequent containers in the same Docker network and configure the reverse proxy serving your Maybe instance to serve your other containers or use some convoluted setup involving Traefik and labeling stuff.
I, for example, have a Caddy on the host doing all the reverse proxying to all containers I have on my server like Immich, Maybe, Nextcloud and a ton of other stuff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to confirm—you're optimizing for a self-hosting scenario here where you're running this app on your local machine and never want it exposed to the internet?
In your reverse-proxy setup are you assuming you'd run a container (e.g. Traefik), or would you be setting up something like Nginx on the host machine instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zachgoll Me? No, I'm optimizing for any sane self-hosting solution - there's a reverse proxy in front of the Rails application. If we put a reverse proxy in the Docker Compose configuration and the self-hosting user has some other services on the server, then they have two options.
- Modify the provided
docker-compose.yml
so the reverse proxy doesn't listen on ports 80 and 443 and point their preferred reverse proxy to... reverse proxy to our reverse proxy - Dump all their other containerized services into the same Docker network and reconfigure our reverse proxy to work with their services.
Maybe there's some convoluted setup leveraging Treafik, but that's not we are aiming for
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m just a self-hosting bystander here, but I would tend to agree with @gantoine. I’ve never seen Docker compose set ups specifically bind the network interface. And, even on a VPS set up, you would need to specifically allow that port in the firewall; at least that’s how it has been for any fresh box I have installed.
I also think if most people are using a Docker set up, there’s no real need to “dumb it down”. They should already understand the risks with simple networking concepts like ports and how to securely access your apps over the internet. I think the Render deploy option serves that purpose well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A very, very simplified example:
# docker-compose.yml
# Ours will be slightly more verbose
services:
backend:
image: traefik/whoami
command:
- --port=6666
ports:
- "127.0.0.1:6666:6666"
# docker-compose.override.yml
services:
caddy:
image: caddy:alpine
command: caddy reverse-proxy --from <domain to serve on> --to backend:6666
ports:
- 80:80
- 443:443
If you docker-compose up -d
with those two files, then you end up with a backend container and a reverse proxy for it with automatic HTTPS. We can supply a custom Caddyfile OR use Traefik as the reverse proxy if we need a more complicated setup
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’ve never seen Docker compose set ups specifically bind the network interface. And, even on a VPS set up, you would need to specifically allow that port in the firewall; at least that’s how it has been for any fresh box I have installed.
@csmith1210 All OVH and Hetzner boxes I have had so far accept all connections on all ports, unless you explicitly configure them not to do so. Assuming your boxes don't (which is a good thing) accept all connections, then binding to 127.0.0.1:<port>
changes exactly nothing in your case, while being a sane default for all boxes which don't have a firewall. I remain unconvinced that binding to 0.0.0.0
and exposing the backend container directly to the internet is a good idea.
I also think if most people are using a Docker set up, there’s no real need to “dumb it down”.
I don't think we're dumbing anything down here. We're trying to provide sane and secure defaults while giving people option to just run it
without any tinkering. If someone wants to tinker with this setup, then they have all the escape hatches (docker-compose.override.yml
and the ability to bring their own reverse proxy)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
binding to 127.0.0.1: changes exactly nothing in your case, while being a sane default for all boxes which don't have a firewall.
This is a fair point, and as it personally wouldn't affect me (as I know how to edit a compose file) I think it's fine to set it that way in the default. 👍🏼
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think @Quintasan's proposal here makes sense overall:
- Give a sane default that "just works"
- Provide escape hatches for those with the knowledge and/or motivation to customize
Still not 100% certain on the port binding (mostly because I've never seen or done this), but I'd rather be conservative to start and then make modifications (or allow the user to make those mods) if needed.
config/environments/production.rb
Outdated
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. | ||
config.force_ssl = true | ||
config.force_ssl = ENV["DISABLE_SSL"].blank? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
config.force_ssl = ENV["DISABLE_SSL"].blank? | |
config.force_ssl = ENV.include?("FORCE_SSL") |
I think the default assumption is that you're using a reverse proxy to terminate incoming HTTPS traffic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We've been back and forth on this one quite a bit, but as I was going back through prior discussions, found this comment, which IMO is the clearest solution offered to this:
config.force_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch('RAILS_FORCE_SSL', true))
config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch('RAILS_ASSUME_SSL', true))
@Radu-C-Martin this looks good, thanks for the changes! The documentation and overall structure is good with me. If I'm not mistaken, the only pending item we have left is the reverse proxy config and the "override" file mentioned by @Quintasan. I think this may be a good follow-up PR. Unless there are objections, I think we can merge this as-is so we can start getting some images published to GHCR for self-hosters to start playing around with. |
Hey, Zach! Thanks for merging my PR 🙂 I tried accessing the image and apparently I'm not authorized. I think there could be some settings missing in the organization settings? (this is what I found, but could be out of date: https://github.com/orgs/community/discussions/26014, I've never had to deal this yet). I see that the CI passes correctly, so I assume for you the image works at least? |
@Radu-C-Martin thanks for the heads up, we'll get the org setting updated shortly. |
All good now! |
My attempt at implementing Issue #627.
I'm for sure missing some stuff, so please don't hesitate to mention it.
The
force_ssl
environment configs are based on this discussion.