<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Certbot on KJ6LNH</title><link>https://kj6lnh.org/tags/certbot/</link><description>Recent content in Certbot on KJ6LNH</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sat, 25 Jul 2026 13:25:00 -0700</lastBuildDate><atom:link href="https://kj6lnh.org/tags/certbot/index.xml" rel="self" type="application/rss+xml"/><item><title>Solving Android CA Trust Issues for Local Homelab Services with Route 53, Let's Encrypt, and BIND9</title><link>https://kj6lnh.org/solving-android-ca-trust-issues-for-local-homelab-services-with-route-53-lets-encrypt-and-bind9/</link><pubDate>Sat, 25 Jul 2026 13:25:00 -0700</pubDate><guid>https://kj6lnh.org/solving-android-ca-trust-issues-for-local-homelab-services-with-route-53-lets-encrypt-and-bind9/</guid><description>How to get native Android apps for Nextcloud and OpenProject working over trusted HTTPS without rooting your phone, exposing internal IPs, or opening router ports.</description><content:encoded><![CDATA[<p>I run OpenProject and Nextcloud on my home network, both behind an internal
certificate authority I run with PicoCA. Browsers on my laptop are happy to trust
that root cert once I install it, but the
official Android apps for both services flatly refuse to talk to a server whose
certificate they don&rsquo;t recognize. No amount of &ldquo;install anyway&rdquo; in Android&rsquo;s
certificate settings fixes it, and I didn&rsquo;t want to root my phone just to sync a
project board and a file share.</p>
<p>Working through this turned into a real rabbit hole — DNS, IAM policy, Nginx syntax
deprecations, a Docker symlink trap, and eventually an OpenProject version upgrade I
wasn&rsquo;t planning on doing that day. I used an AI assistant (Gemini) as a sounding
board while I worked through it, and this post is the writeup of what I actually did
and why, with the domain names swapped for <code>example.com</code> and <code>example.local</code>.</p>
<h2 id="why-android-wont-just-trust-my-ca">Why Android won&rsquo;t just trust my CA</h2>
<p>Since Android 7 (API level 24), apps are split into two trust worlds. There&rsquo;s the
<strong>system</strong> trust store — the CAs baked into the OS — and the <strong>user</strong> trust store,
where anything you install yourself through Settings lands. By default, apps built
against a modern <code>targetSdkVersion</code> only trust the system store. Installing my
PicoCA root as a user certificate makes it show up correctly in a browser, but
OpenProject&rsquo;s and Nextcloud&rsquo;s native apps use Android&rsquo;s standard network stack with
its default network security config, which ignores user certificates entirely.</p>
<p>The paths around this all had problems:</p>
<ul>
<li><strong>Root the phone</strong> and use a Magisk module to promote the CA into the system
store. Every app would trust it, but I didn&rsquo;t want to root my daily driver phone
for this.</li>
<li><strong>Decompile and re-sign the APKs</strong> with a patched <code>network_security_config.xml</code>
that explicitly trusts user CAs. This works, but it means maintaining a
hand-patched build of someone else&rsquo;s app forever, through every update.</li>
<li><strong>Get a real publicly-trusted certificate.</strong> Let&rsquo;s Encrypt won&rsquo;t touch <code>.local</code> —
it&rsquo;s not a valid public TLD, and DNS-01 validation requires a domain a public CA
can actually verify ownership of. My internal domain, <code>nextcloud.example.local</code>,
was a non-starter from the get-go.</li>
</ul>
<p>The way out was to stop trying to make Android trust a private CA at all, and
instead get a real Let&rsquo;s Encrypt certificate for services that never touch the
public internet.</p>
<h2 id="split-horizon-dns-with-a-wildcard-cert">Split-horizon DNS with a wildcard cert</h2>
<p>I already own a real domain on Route 53. The trick is that owning a domain and
<em>hosting a public website on it</em> are two different things — nothing requires me to
publish A records for <code>openproject.example.com</code> or <code>nextcloud.example.com</code> at all.</p>
<p>The plan:</p>
<ul>
<li><strong>Route 53 stays empty.</strong> No A or CNAME records for either service. The only
thing that ever touches Route 53 is a temporary <code>_acme-challenge</code> TXT record that
Certbot creates and deletes on every renewal, via the DNS-01 challenge. Anyone
running <code>dig openproject.example.com</code> from the outside world gets <code>NXDOMAIN</code>.</li>
<li><strong>My local BIND9 resolver overrides those two names</strong> to the internal LAN IP
running Nginx.</li>
<li><strong>One wildcard certificate</strong>, <code>*.example.com</code>, covers every internal service I
ever add. No more juggling per-service PicoCA leaf certs.</li>
</ul>
<p>My first instinct was actually simpler and wrong: create public CNAME records
pointing <code>openproject.example.com</code> at <code>openproject.example.local</code>, so a <code>dig</code> from
the outside would resolve to something meaningless without exposing an IP. That
doesn&rsquo;t work, and not just because it leaks the fact that a service called
&ldquo;openproject&rdquo; exists. A CNAME is an alias, not a shortcut — the resolver a client
is actually asking (Google&rsquo;s <code>8.8.8.8</code>, a carrier&rsquo;s DNS, whatever) has to be able to
resolve the <em>target</em> too. <code>.local</code> only means anything to my own BIND9 server, so
any public resolver hits the CNAME, tries to chase it to <code>example.local</code>, and comes
back with <code>NXDOMAIN</code>. Recursive resolution happens server-side, on whichever
resolver the client asked — not on the client. If I want split-horizon DNS to
actually route traffic, the override has to happen on a resolver my devices are
actually using, which means my own BIND9 server.</p>
<h2 id="getting-bind9-to-override-selectively">Getting BIND9 to override selectively</h2>
<p>I already run BIND9 as my network&rsquo;s resolver, using views to apply different rules
to different clients (I have a <code>blocked</code> view that null-routes certain domains for
specific devices). I wanted <code>openproject.example.com</code> and <code>nextcloud.example.com</code>
to resolve to my Nginx box&rsquo;s LAN IP, while every other name — including anything
else under <code>example.com</code> — passed through to public DNS normally.</p>
<p>That selective override is what a BIND <strong>Response Policy Zone (RPZ)</strong> is for: a
small zone of override rules that BIND consults before falling back to normal
resolution, rather than making BIND authoritative for the whole <code>example.com</code> zone
(which would silently <code>NXDOMAIN</code> every subdomain I hadn&rsquo;t explicitly listed).</p>
<p>My first attempt at wiring it up threw:</p>
<pre tabindex="0"><code>rpz &#39;rpz.local&#39; is not a primary or a secondary zone
</code></pre><p>The cause was that I&rsquo;d declared <code>response-policy { zone &quot;rpz.local&quot;; };</code> inside the
global <code>options {}</code> block, but BIND was already using named <code>view</code>s for split
client policy (<code>blocked</code> and <code>default</code>). Response policy has to be declared inside
each view where you want it applied — a global declaration can&rsquo;t reach into a zone
that&rsquo;s only defined inside a view. The fix was to move <code>response-policy</code> into each
view block and define <code>rpz.local</code> as a zone inside those same views:</p>
<pre tabindex="0"><code>view &#34;default&#34; {
    match-clients { any; };

    response-policy {
        zone &#34;rpz.local&#34;;
    };

    zone &#34;rpz.local&#34; {
        type master;
        file &#34;/var/lib/bind/db.rpz.local&#34;;
        allow-query { localhost; 192.168.0.0/16; };
    };
};
</code></pre><p>And the RPZ zone file itself just lists the two overrides:</p>
<pre tabindex="0"><code>$TTL 60
@       IN      SOA     localhost. root.localhost. ( 2026072401 1h 15m 1w 1d )
        IN      NS      localhost.

openproject.example.com  IN  A   192.168.x.x
nextcloud.example.com    IN  A   192.168.x.x
</code></pre><p>With that in place, a device using my BIND9 server gets the LAN IP for those two
names and normal public resolution for everything else — <code>dig</code> for any other
<code>example.com</code> subdomain sails straight through.</p>
<h2 id="locking-down-the-iam-user-for-dns-01">Locking down the IAM user for DNS-01</h2>
<p>Certbot&rsquo;s <code>certbot-dns-route53</code> plugin needs API access to create and delete the
<code>_acme-challenge</code> TXT record on every renewal. I wanted an IAM policy scoped as
tightly as possible: this key should not be able to touch anything except TXT
records in my one hosted zone.</p>
<p>Route 53&rsquo;s <code>ChangeResourceRecordSets</code> doesn&rsquo;t have a record-type parameter by
default, but it does expose a condition key,
<code>route53:ChangeResourceRecordSetsRecordTypes</code>, that lets you restrict <em>which record
types</em> an API call is allowed to modify. Because a single <code>ChangeResourceRecordSets</code>
call can bundle multiple changes, that key is multi-valued, and IAM requires an
explicit set qualifier (<code>ForAllValues</code> or <code>ForAnyValue</code>) before it&rsquo;ll accept the
condition — leaving it off produces a &ldquo;missing qualifier&rdquo; policy validation error.
<code>ForAllValues:StringEquals</code> says <em>every</em> record type touched by the call must be
<code>TXT</code>, which is exactly what I want:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;CertbotListAndGetHostedZone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Allow&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;route53:GetHostedZone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;route53:ListResourceRecordSets&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;arn:aws:route53:::hostedzone/Z0123456789EXAMPLE&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;CertbotManageTXTRecordsOnly&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Allow&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="s2">&#34;route53:ChangeResourceRecordSets&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;arn:aws:route53:::hostedzone/Z0123456789EXAMPLE&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Condition&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nt">&#34;ForAllValues:StringEquals&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;route53:ChangeResourceRecordSetsRecordTypes&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;TXT&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;CertbotGlobalActions&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Allow&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;route53:ListHostedZones&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;route53:GetChange&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">],</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Scoping the resource ARN to my one hosted zone means this key can never touch any
other zone in the account, and the TXT-only condition means that even if it leaked,
whoever had it could only ever create or delete challenge TXT records — not
repoint an A record or hijack anything that actually resolves.</p>
<h2 id="certbot-on-debian-trixie-credentials-kept-out-of-aws">Certbot on Debian trixie, credentials kept out of <code>~/.aws</code></h2>
<p>Debian 13 enforces PEP 668, so a bare <code>pip install certbot</code> is a non-starter.
<code>pipx</code> handles that cleanly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt install -y pipx
</span></span><span class="line"><span class="cl">pipx ensurepath
</span></span><span class="line"><span class="cl">pipx install certbot
</span></span><span class="line"><span class="cl">pipx inject certbot certbot-dns-route53
</span></span></code></pre></div><p>I didn&rsquo;t want the IAM credentials sitting in <code>~/.aws/credentials</code>, so they live in
a root-only file instead, and a small wrapper script loads them into the
environment before invoking Certbot:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /etc/letsencrypt/aws
</span></span><span class="line"><span class="cl">chmod <span class="m">700</span> /etc/letsencrypt/aws
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /etc/letsencrypt/aws/route53.env   <span class="c1"># AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ENV_FILE</span><span class="o">=</span><span class="s2">&#34;/etc/letsencrypt/aws/route53.env&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -a
</span></span><span class="line"><span class="cl"><span class="nb">source</span> <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> +a
</span></span><span class="line"><span class="cl">/root/.local/bin/certbot <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>The initial wildcard cert:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">certbot-route53.sh certonly <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dns-route53 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s2">&#34;example.com&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s2">&#34;*.example.com&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --agree-tos <span class="se">\
</span></span></span><span class="line"><span class="cl">  -m <span class="s2">&#34;admin@example.com&#34;</span>
</span></span></code></pre></div><p>And a cron entry that renews twice daily (only actually renewing within 30 days of
expiry) and reloads Nginx on success:</p>
<pre tabindex="0"><code>27 3,15 * * * /usr/local/bin/certbot-route53.sh renew --quiet --post-hook &#34;docker exec docker-nginx-1 nginx -s reload&#34;
</code></pre><p><code>nginx -s reload</code> picks up new cert files without dropping active connections,
which matters for anything holding a long-lived sync connection.</p>
<h2 id="the-docker-symlink-trap">The Docker symlink trap</h2>
<p>Nginx runs in <code>docker-compose</code>, so the certificate needs to get into that
container. My first pass mounted just the live directory:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">/etc/letsencrypt/live/example.com:/etc/letsencrypt/live/example.com:ro</span><span class="w">
</span></span></span></code></pre></div><p>This looks reasonable and will bite you the moment Nginx tries to actually read
the files. Everything under <code>/etc/letsencrypt/live/&lt;domain&gt;/</code> — <code>fullchain.pem</code>,
<code>privkey.pem</code> — is a <strong>symlink</strong>, and the targets are relative paths pointing into
<code>/etc/letsencrypt/archive/&lt;domain&gt;/</code>. Mount only the <code>live</code> directory into a
container and those symlinks point at a path that doesn&rsquo;t exist inside it; Nginx
fails to start with a plain &ldquo;no such file or directory,&rdquo; which is a confusing error
for what&rsquo;s really a missing bind mount. The fix is to mount the whole
<code>/etc/letsencrypt</code> tree so <code>live</code> and <code>archive</code> are both present and the relative
symlinks resolve correctly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">/etc/letsencrypt:/etc/letsencrypt:ro</span><span class="w">
</span></span></span></code></pre></div><h2 id="nginx-certpem-vs-fullchainpem-and-a-deprecated-directive">Nginx: cert.pem vs. fullchain.pem, and a deprecated directive</h2>
<p>My first server block used <code>cert.pem</code> instead of <code>fullchain.pem</code>. Browsers often
paper over this by fetching missing intermediates themselves, but Android&rsquo;s native
TLS stack does not — it strictly validates the chain it&rsquo;s handed and rejects
anything incomplete with <code>SSL_ERROR_UNKNOWN_CA_SAME_ISSUER</code>. <code>fullchain.pem</code>
includes Let&rsquo;s Encrypt&rsquo;s intermediate, and that error went away immediately once I
switched to it.</p>
<p>Separately, current Nginx (1.25.1+) deprecated putting <code>http2</code> directly on the
<code>listen</code> line:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="c1"># old, produces warnings
</span></span></span><span class="line"><span class="cl"><span class="k">listen</span> <span class="mi">443</span> <span class="s">ssl</span> <span class="s">http2</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># current
</span></span></span><span class="line"><span class="cl"><span class="k">listen</span> <span class="mi">443</span> <span class="s">ssl</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">http2</span> <span class="no">on</span><span class="p">;</span>
</span></span></code></pre></div><p>Leaving the old form in place across multiple server blocks sharing port 443 also
throws a second warning — <code>protocol options redefined for 0.0.0.0:443</code> — because
Nginx binds socket-level options from the first server block it evaluates for that
port, and conflicting <code>listen ... http2</code> lines in later blocks look like a
redefinition. Switching every <code>server{}</code> block to the standalone <code>http2 on;</code> form
cleared both warnings.</p>
<h2 id="getting-the-apps-themselves-to-accept-the-new-domain">Getting the apps themselves to accept the new domain</h2>
<p>With DNS, the cert, and Nginx all sorted, hitting the new URLs returned application-level
errors, not TLS errors — which was actually reassuring, since it meant everything
below the app layer was working:</p>
<ul>
<li>
<p><strong>Nextcloud</strong> returned &ldquo;Access through untrusted domain.&rdquo; Nextcloud keeps its own
allowlist of hostnames independent of TLS, in <code>config.php</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="s1">&#39;trusted_domains&#39;</span> <span class="o">=&gt;</span> <span class="k">array</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="mi">0</span> <span class="o">=&gt;</span> <span class="s1">&#39;192.168.x.x&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="mi">1</span> <span class="o">=&gt;</span> <span class="s1">&#39;nextcloud.example.local&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="mi">2</span> <span class="o">=&gt;</span> <span class="s1">&#39;nextcloud.example.com&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">),</span>
</span></span></code></pre></div></li>
<li>
<p><strong>OpenProject</strong> returned &ldquo;Invalid host_name configuration,&rdquo; fixed under
<strong>Administration → System settings → Display</strong> by setting the host name to
<code>openproject.example.com</code> and the protocol to <code>https</code>.</p>
</li>
</ul>
<p>Once both apps loaded fine in a browser, the Nextcloud Android app got through
login but hung forever spinning on the final &ldquo;Grant Access&rdquo; screen. The cause
turned out to be that Nextcloud didn&rsquo;t know it was sitting behind a reverse proxy
terminating TLS, so it was generating its OAuth-style callback links as <code>http://</code>
instead of <code>https://</code>, and the phone app was refusing to follow them. Nextcloud&rsquo;s
fix for exactly this is a small set of proxy-awareness settings:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="s1">&#39;overwritehost&#39;</span> <span class="o">=&gt;</span> <span class="s1">&#39;nextcloud.example.com&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s1">&#39;overwriteprotocol&#39;</span> <span class="o">=&gt;</span> <span class="s1">&#39;https&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s1">&#39;overwrite.cli.url&#39;</span> <span class="o">=&gt;</span> <span class="s1">&#39;https://nextcloud.example.com&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s1">&#39;trusted_proxies&#39;</span> <span class="o">=&gt;</span> <span class="k">array</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="mi">0</span> <span class="o">=&gt;</span> <span class="s1">&#39;192.168.x.x&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">),</span>
</span></span></code></pre></div><p><code>overwriteprotocol</code> forces every internally generated link — including the grant
callback — to use <code>https</code>, and <code>trusted_proxies</code> tells Nextcloud it can trust the
<code>X-Forwarded-Proto</code> header Nginx is already sending. Force-closing and reopening
the app, the grant screen redirected instantly.</p>
<h2 id="the-openproject-oauth-rabbit-hole">The OpenProject OAuth rabbit hole</h2>
<p>OpenProject&rsquo;s app got further but hit a wall logging in, with a mostly blank
authorization screen showing only the tail end of an error message — &ldquo;&hellip;this
method.&rdquo; — and this in the server log:</p>
<pre tabindex="0"><code>method=GET path=/oauth/authorize controller=Doorkeeper::AuthorizationsController action=new status=401
</code></pre><p>The full error is OpenProject&rsquo;s standard Doorkeeper OAuth rejection: <em>&ldquo;The client
is not authorized to perform this request using this method.&rdquo;</em> The usual fix is
enabling built-in OAuth applications under <strong>Administration → Authentication →
OAuth applications</strong>. Except there was no such setting to enable — no checkbox, no
default applications listed at all. That absence turned out to be the real clue:
built-in OAuth applications for the mobile app are a feature of OpenProject 17,
and I was running version 15.</p>
<p>Upgrading turned into its own small saga. OpenProject&rsquo;s database migrations only
support moving one major version at a time, so jumping straight to 17 refused to
start. Going to 16 first worked and ran its schema migration — but 16 also wanted
PostgreSQL upgraded from 13 to 17, so that came first: dump, restore into a
PostgreSQL 17 container, confirm it, <em>then</em> move OpenProject from 16 to 17. Once
that chain was done, the built-in OAuth toggle appeared, and the app connected on
the first try.</p>
<h2 id="wrapping-up">Wrapping up</h2>
<p>None of the individual pieces here were exotic — Route 53, Certbot, BIND9, Nginx,
and two apps&rsquo; proxy-awareness settings are all well-documented on their own. What
made this a genuine multi-day project was that Android&rsquo;s CA trust model forces
every one of those pieces to actually work end to end before you see anything
other than an error: split-horizon DNS has to route the name correctly, the cert
has to be the full chain, Nginx has to speak modern HTTP/2 without warnings, the
app has to have the new hostname on its allowlist, and it has to know it&rsquo;s
sitting behind HTTPS. Skip any one link and you get a different, unrelated-looking
failure instead of a hint about what&rsquo;s actually wrong.</p>
<p>The result is worth it, though: one wildcard certificate, zero public DNS records
for anything that resolves internally, an IAM key that can only ever create or
delete a TXT record, and both apps working natively on an un-rooted phone without
ever touching Android&rsquo;s certificate settings again.</p>
]]></content:encoded></item></channel></rss>