<p>In September, I <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/">transitioned maru's over-the-air updates</a> from my self-hosted expo-updates server to Codemagic Patch. The initial discussion revolved around testing Patch on my local environment, while production deployment specifics were left as an intention to document later.</p>
<p>This article serves as that update. Since October 5, maru's <a href="https://patch.codemagic.io">Patch</a> server has been operational on Fly.io, running on one of their smallest available machines, utilizing Tigris for file storage. All updates delivered to users are routed through this newly configured server, a process I completed manually in about an afternoon, learning several nuances along the way about Tigris and Fly that weren’t covered in the documentation.</p>
<p>However, with the release of Patch 0.8 on October 28, setting up on Fly.io has become significantly streamlined. The new CLI installation process simplifies the setup to a single command from your laptop, requiring only three questions and two browser sign-ins, leading to a configuration identical to the one I created manually, albeit with optimizations based on my earlier lessons.</p>
<p>The latter part of this article delves into the use of OTA updates in the development phase, which proved to be incredibly beneficial for efficiently deploying new code onto testers' devices without initiating a native build. I’ll also cover maru's experiences in its initial two weeks in a production environment with Patch.</p>
<h2 id="what-has-to-run">System Requirements</h2>
<p>The Patch server comprises a single Node process that, when operating in <code>MODE=all</code>, handles the API, background tasks for processing uploaded bundles, and the web dashboard—all from one container. To support this server, three external components are necessary:</p>
<ul>
<li>Postgres for managing applications, releases, users, and job queues.</li>
<li>S3-compatible object storage for bundles and their corresponding manifests.</li>
<li>A sign-in provider, such as GitHub, GitLab, or Bitbucket; the server will not start without one.</li>
</ul>
<p>This design choice facilitates cost-effective hosting, as devices do not query the server for updates. Instead, upon publishing a release, JSON manifests are written to storage, allowing applications to access them directly. Update checks involve simple GET requests against a bucket, minimizing server interaction and ensuring that even if the server goes down, users can still access the latest published release. For a more detailed analysis of scalable OTA architecture for React Native, refer to the relevant documentation.</p>
<p>On Fly.io, the setup consists of one application for Patch, a database, and two storage buckets. Phones retrieve manifests and bundles directly from a public Tigris bucket, while install reports are sent back to the Patch server. The CLI and dashboard communicate with the same server, with the server writing to Postgres, the public bucket, and a second private bucket for upload staging before publication.</p>
<h2 id="one-command">Simplified Setup</h2>
<p>The installation wizard is a new feature included in Patch's CLI as of version 0.8.0, executing on your local machine. Its operation utilizes Fly's and Tigris's command-line tools, sidestepping the need for a personal server or domain:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ npm install -g @codemagic/patch-cli</span></span>
<span style="display:flex"><span>$ cmpatch selfhost install --fly</span></span>
</code>
</pre>
</div>
<p>I executed these commands on October 29 in my personal Fly organization, labeling it <code>Maru Demo</code>. The output below reflects the process I underwent.</p>
<p>The installation wizard begins by verifying account statuses. If either <code>flyctl</code> or the Tigris CLI is not installed, it prompts for installations. Following this, the wizard will confirm your Fly account status, assisting with sign-in or registration as needed. It will also check the billing status of your Fly organization, guiding you to set up payment if necessary. A new card will activate billing and transition you out of the free trial phase; however, the costs are minor, generally amounting to a few dollars monthly.</p>
<p>Next, it poses three questions:</p>
<ul>
<li><strong>Name your company:</strong> This will define the naming conventions for all elements created, including the Fly application and storage buckets.</li>
<li><strong>Select a region:</strong> Although seven common options are provided, the "Other" option allows for any Fly region code. Since phone downloads occur from Tigris rather than this setup, it's advisable to choose a region close to both your location and your database.</li>
<li><strong>Do you have an existing Postgres database?</strong> If so, provide the URL; otherwise, it will generate one on Fly.</li>
</ul>
<p>Once these answers are processed, the wizard proceeds through the following actions:</p>
<ol>
<li>Create the storage buckets.</li>
<li>Instruct you to generate an access key for both buckets via Tigris; the CLI will direct you to the browser for confirmation.</li>
<li>Perform checks on the access key, including uploading a test file to ensure the public bucket serves correctly while denying access to the private one.</li>
<li>Guide you through creating a sign-in application on GitHub, GitLab, or Bitbucket.</li>
<li>As needed, establish the Postgres machine.</li>
<li>Compile the latest Patch server release on Fly’s builder and deploy it, awaiting confirmation of the server's responsiveness.</li>
</ol>
<p>The entire process post-inquiry took approximately 7 minutes and 15 seconds: 1 minute and 22 seconds for the Postgres machine setup, 25 seconds to retrieve the server release, and a duration of 5 minutes and 25 seconds for the build and deployment. The bucket creations and validation checks took around six seconds in total. Altogether, from the first question to completion, it spanned about half an hour.</p>
<p>Tip: It’s beneficial to log in to Fly.io through your browser before commencing the setup. The Tigris code has a limited validity of roughly five minutes, which can pause your installation process if it expires mid-setup. In my initial attempt, this criteria led to the wizard cleanly halting, and upon re-execution, it recognized the pre-existing buckets and offered to reuse them.</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>Patch is running.</span></span>
<span style="display:flex"><span>Dashboard: https://maru-demo-patch.fly.dev</span></span>
<span style="display:flex"><span>Download URL: https://maru-demo-patch.t3.tigrisfiles.io</span></span>
<span style="display:flex"><span>Note: Changing either address mandates a new store build.</span></span>
<span style="display:flex"><span>Sign in with GitHub to create the admin account:</span></span>
<span style="display:flex"><span> cmpatch login --server-url https://maru-demo-patch.fly.dev</span></span>
<span style="display:flex"><span> cmpatch init --server-url https://maru-demo-patch.fly.dev</span></span>
<span style="display:flex"><span>The database disk comprises a single volume; Fly's daily snapshot is the sole backup provisioned by this command.</span></span>
</code>
</pre>
</div>
<p>Once the installation is completed, sign in to the dashboard briefly to create the admin account. Following this, use <code>cmpatch login</code> to connect your machine, and run <code>cmpatch init</code> within your app’s directory to generate an app for each platform on the new server while integrating the SDK into your project. This concludes the installation; I manually executed these last steps for maru, and the details can be found below.</p>
<figure class="size- align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/dash-status-wizard.png" alt="The new server's status page, minutes after the install." title=""/>
<figcaption>The new server's status page, minutes after the install.</figcaption>
</figure>
<p>The wizard records the installation on the originating machine, which allows subsequent upgrade, backup, and restore commands to operate without needing further information. Additionally, if your CLI was previously directed at another server (in my case, the local evaluation stack), it will prompt whether to switch.</p>
<h2 id="what-the-wizard-builds-and-why">Understanding the Wizard Setup</h2>
<p>Initially, I configured maru's server manually in early October before the wizard's existence. While the architecture remains consistent with what the wizard now creates, notable differences include its dependency on an existing Postgres cluster for maru and a legacy Tigris hostname for the download address. The following section outlines the insights gained during this manual process to help you understand the structure of your Fly account, as two specific values will feature in every binary deployed.</p>
<h3 id="two-buckets-because-tigris-has-no-bucket-policies">Two Buckets Necessity</h3>
<p>Patch’s bundled configuration operates MinIO and maintains everything in a single bucket, which must primarily be public due to mobile access requirements. However, one prefix, designated <code>_internal/</code>, must remain private to delay the publishing of uploaded bundles until ready. The bundled system accomplishes this through a bucket policy allowing public reads while securing the <code>_internal/*</code> path:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-json" data-lang="json">
<span style="display:flex"><span>{
</span></span>
<span style="display:flex"><span> "Statement": [
</span></span>
<span style="display:flex"><span> { "Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"],
</span></span>
<span style="display:flex"><span> "Resource": ["arn:aws:s3:::codemagic-patch/*"] },
</span></span>
<span style="display:flex"><span> { "Effect": "Deny", "Principal": "*", "Action": ["s3:GetObject"],
</span></span>
<span style="display:flex"><span> "Resource": ["arn:aws:s3:::codemagic-patch/_internal/*"] }
</span></span>
<span style="display:flex"><span> ]
</span></span>
<span style="display:flex"><span>}
</span></span>
</code>
</pre>
</div>
<p>During maru's setup, I created a Tigris bucket using that policy:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ aws s3api put-bucket-policy --bucket maru-patch-artifacts
</span></span>
<span style="display:flex"><span> --policy file://policy.json --endpoint-url https://fly.storage.tigris.dev
</span></span>
<span style="display:flex"><span>An error occurred (NotImplemented) when calling the PutBucketPolicy operation:
</span></span>
<span style="display:flex"><span>A header you provided implies functionality that is not implemented
</span></span>
</code>
</pre>
</div>
<p>Tigris currently lacks bucket policies; each bucket can be either private or public in its entirety. If the bucket is public, uploads within the <code>_internal/</code> directory are also public, meaning anyone with the correct URL can access unpublished bundles or those that have been reverted.</p>
<p>Patch provides a solution via the <code>S3_INTERNAL_BUCKET</code> option. Once set, all <code>_internal/</code> keys redirect to a second private bucket, thereby keeping the public bucket reserved solely for items necessary for phones. In my manual setup, I pieced together this solution from the Compose guide; the wizard streamlines that by creating two buckets labeled <code>maru-demo-patch</code> (public) and <code>maru-demo-patch-internal</code> (private) from the outset. Should you opt for a different storage provider that lacks bucket policies, implementing a similar structure is advised.</p>
<p>The key verification in step 3 was conducted by testing the buckets under anonymous access:</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
<tr>
<td>Accessing a published file in the public bucket</td>
<td>200</td>
</tr>
<tr>
<td>Listing contents of the public bucket</td>
<td>403</td>
</tr>
<tr>
<td>Attempting to access anything in the private bucket</td>
<td>403</td>
</tr>
<tr>
<td>Listing contents of the private bucket</td>
<td>403</td>
</tr>
</tbody>
</table>
<p>Interestingly, a public Tigris bucket does not allow for listing its contents.</p>
<p>Tigris reveals an access key's secret only once during creation. The wizard directly inputs this key into the Fly app's secrets, eliminating the need for manual handling. I once lost the access key by mistakenly filtering it out of my terminal for security, prompting a need to delete and recreate the bucket under a different name, as Tigris reserves names for a period once a bucket is deleted.</p>
<h3 id="the-download-address">Configuring the Download Address</h3>
<p>The <code>PUBLIC_BASE_URL</code> designates where phones download data. The wizard configures this to the public bucket's hostname, <code>https://maru-demo-patch.t3.tigrisfiles.io</code>. Conversely, during my manual setup, I used <code>fly.storage.tigris.dev</code>. Both serve the bucket in the same manner, where the hostname represents the bucket directly rather than as a subfolder.</p>
<p>This was the error I encountered in September: I mistakenly documented the address as <code>PUBLIC_BASE_URL=https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</code>, which incorrectly appends a subpath to the bucket name. Since Patch places its files at the root, such a URL results in failed queries for existing manifests.</p>
<p>This discrepancy is critical as this address is embedded in every app binary. Incorrectly configured, it leads to updates failing to arrive for users. The wizard proactively verifies that the bucket responds correctly at its designated address before concluding, eliminating such mishaps. Nonetheless, it’s wise to verify following your initial publication: retrieve a manifest using <code>curl</code> as illustrated below.</p>
<h3 id="the-database">Database Configuration</h3>
<p>Should you permit the wizard to establish the database, it will generate a second Fly app named <code>maru-demo-patch-db</code>, utilizing the official <code>postgres:16-alpine</code> container on a 1 GB volume, all situated on a shared machine that suspends when idle. Patch accesses it via Flycast, Fly’s internal network proxy at <code>maru-demo-patch-db.flycast</code>. This detail is critical, as it allows the sleeping database to function. A <code>.internal</code> address would link directly to the machine, preventing it from awakening if it's suspended. If you opt to input your existing URL, the wizard won't accept <code>.internal</code> references under these conditions.</p>
<p>Fly automatically takes daily snapshots of the volume, serving as the only backup provided by the install. The wizard indicates this during the finalization process. Running <code>cmpatch selfhost backup</code>, as detailed at the end of this article, expands backup options.</p>
<p>A 1 GB allocation accommodates substantial growth. Post version 0.8, the server retains individual install events for eight days before consolidating them into daily totals. This metric emphasizes that database size expands with the number of active devices running your app and subsequently stabilizes. The documentation estimates approximately 2 GB for every 100,000 daily users, suggesting planning for disk space expansion as usage increases.</p>
<p>maru, however, utilizes an alternative method. With its API already functioning on Fly’s Managed Postgres, it can accommodate a new database and user within an existing cluster (using <code>fly mpg databases create</code> and <code>fly mpg users create</code> with <code>schema_admin</code> privileges, as Patch independently executes its migrations upon startup). Managed Postgres connects through PgBouncer in transaction mode, which can conflict with functions requiring session persistence across transactions (such as session-level advisory locks, <code>LISTEN</code>, and server-side cursors). Following a cursors issue noted in September, I checked Patch’s server's behavior first. Its migrations and metrics utilize transaction-scoped advisory locks (<code>pg_advisory_xact_lock</code>), securely released upon transaction closure, ensuring compatibility with the connection pooler.</p>
<p>If you lack a PostgreSQL database, enabling the wizard to create one is advisable. Setting up a dedicated Managed Postgres cluster is significantly more costly than the singular machine utilizing it.</p>
<h3 id="the-server">Server Configuration</h3>
<p>This is the <code>fly.toml</code> file generated by the wizard for <code>Maru Demo</code> in Tokyo during this post:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-toml" data-lang="toml">
<span style="display:flex"><span>app = "maru-demo-patch"</span></span>
<span style="display:flex"><span>primary_region = "nrt"</span></span>
<span style="display:flex"><span>[build]</span></span>
<span style="display:flex"><span> dockerfile = "server/Dockerfile"</span></span>
<span style="display:flex"><span>[env]</span></span>
<span style="display:flex"><span> MODE = "all"</span></span>
<span style="display:flex"><span> STORAGE_ADAPTER = "s3"</span></span>
<span style="display:flex"><span> S3_BUCKET = "maru-demo-patch"</span></span>
<span style="display:flex"><span> S3_INTERNAL_BUCKET = "maru-demo-patch-internal"</span></span>
<span style="display:flex"><span> S3_ENDPOINT = "https://t3.storage.dev"</span></span>
<span style="display:flex"><span> S3_FORCE_PATH_STYLE = "true"</span></span>
<span style="display:flex"><span> S3_REGION = "auto"</span></span>
<span style="display:flex"><span> PUBLIC_BASE_URL = "https://maru-demo-patch.t3.tigrisfiles.io"</span></span>
<span style="display:flex"><span> INITIAL_ADMIN_EMAILS = "admin@example.com"</span></span>
<span style="display:flex"><span> PATCH_SERVER_TAG = "codemagic-patch-server-v0.8.0"</span></span>
<span style="display:flex"><span>[http_service]</span></span>
<span style="display:flex"><span> internal_port = 3000</span></span>
<span style="display:flex"><span> force_https = true</span></span>
<span style="display:flex"><span> auto_stop_machines = "suspend"</span></span>
<span style="display:flex"><span> auto_start_machines = true</span></span>
<span style="display:flex"><span> min_machines_running = 0</span></span>
<span style="display:flex"><span> [[http_service.checks]]</span></span>
<span style="display:flex"><span> method = "GET"</span></span>
<span style="display:flex"><span> path = "/health"</span></span>
<span style="display:flex"><span> interval = "15s"</span></span>
<span style="display:flex"><span> timeout = "5s"</span></span>
<span style="display:flex"><span>[[vm]]</span></span>
<span style="display:flex"><span> cpu_kind = "shared"</span></span>
<span style="display:flex"><span> cpus = 1</span></span>
<span style="display:flex"><span> memory = "512mb"</span></span>
</code>
</pre>
</div>
<p>Executing <code>fly config show</code> on the deployed application demonstrates an identical configuration with the exception of the administrator’s email, which I've altered for privacy.</p>
<p>The configuration closely mirrors what I initially set up for maru manually, though three settings warrant specific attention.</p>
<p>The machine is designed to suspend when idle. My previous caution concerning Fly’s scale-to-zero leading to HTTP 504 errors on maru prompted me to maintain an always-on configuration initially. The concern revolved around the background worker. Publishing is quick, involving simple uploads before the worker processes the necessary tasks. Fly assesses a machine's idleness based on active requests rather than ongoing tasks, thereby risking interruptions during release sequences. The suspension model, however, ensures the machine is preserved in a memory snapshot, allowing unfinished tasks to resume seamlessly. As the phones primarily communicate with the server to report installs only, the machine remains dormant until a request triggers its wake-up within approximately 0.6 seconds.</p>
<p>Next, regarding health checks, the endpoint is set to <code>/health</code> instead of <code>/health/ready</code>. The latter checks the database as well, and I aimed to prevent Fly from impacting the dashboard's availability due to potential database stalls, which are not directly relevant to phone functions.</p>
<p>Since no published server image exists yet, the wizard clones the latest server release tag and entails Fly’s builder to compile it, a process I previously executed manually that initially spanned 5 minutes and 35 seconds. The <code>PATCH_SERVER_TAG</code> serves as a marker for tracking the latest release version built, providing essential information for future upgrades. While operational, maru’s server utilizes 82 MB of its allocated 512 MB.</p>
<p>The wizard also efficiently creates secrets: one for the worker, another for CLI logins, alongside the storage key, database URL, and sign-in application credentials. When provisioning these, it employs <code>fly secrets import</code> through standard input, ensuring sensitive information isn’t disclosed via command line or saved logs.</p>
<h3 id="signing-in">User Authentication</h3>
<p>The server mandates a sign-in provider before it can boot. Consequently, whoever first logs in using the designated administrator email will be the initial account holder. From there on, registrations become invite-only. Due to security protocols, GitHub does not permit OAuth app creation through its API, causing this step to remain browser-dependent. The wizard facilitates this by providing necessary values for user input:</p>
<ul>
<li>Homepage URL: <code>https://maru-demo-patch.fly.dev</code></li>
<li>Authorization callback URL: <code>https://maru-demo-patch.fly.dev/auth/callback</code></li>
</ul>
<p>The wizard requests the client ID and secret, thoroughly validating a GitHub set before proceeding with any deployments.</p>
<p>The administrator email must be the verified primary email associated with the GitHub account. This criterion applies to all users you invite later, presenting a challenge for teams where decision-makers may not be developers. While creating a GitHub account is simple, it could challenge non-technical stakeholders who need access to dashboard functionalities. Additional sign-in options, such as via an emailed link, could help facilitate easier access for such roles.</p>
<p>Users can sign in through the same dashboard via the CLI:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch login --server-url https://maru-demo-patch.fly.dev</span></span>
<span style="display:flex"><span>Complete the sign-in in your browser.</span></span>
<span style="display:flex"><span>Logged in as you@example.com</span></span>
</code>
</pre>
</div>
<pA note of caution: a refresh token is valid for a single use; the dashboard and CLI sessions interpolate similarly, meaning using a session from the CLI in a browser results in logging the CLI out. For automation purposes, it’s advisable to utilize a personal access token:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch token create --name maru-ci</span></span>
</code>
</pre>
</div>
<h2 id="apps-deployments-and-the-first-release">Applications, Deployments, and Initial Release</h2>
<p>Post-installation, every operation ran on maru's dedicated server, <code>maru-patch.fly.dev</code>. With the wizard setup, the process of creating apps is facilitated through <code>cmpatch init</code>, though I manually configured maru’s applications. Each created app results in both Staging and Production deployments, each assigned a deployment key embedded within the app binary:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch app create --name maru-ios</span></span>
<span style="display:flex"><span>$ cmpatch app create --name maru-android</span></span>
<span style="display:flex"><span>$ cmpatch deployment list --app maru-ios --format table</span></span>
<span style="display:flex"><span>NAME DEPLOYMENT_KEY</span></span>
<span style="display:flex"><span>Staging dep_5b0e2c41d8f94a7e9c3a61f02b7d8e19</span></span>
<span style="display:flex"><span>Production dep_a91f3d07c6e24b58b0e4f7c2918d6a3b</span></span>
</code>
</pre>
</div>
<p>Deployment keys themselves are not confidential since any user possessing the app can access them. My publishing protocol for the previous article has now been adjusted to point toward Fly:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>═══ Patch OTA Publisher ═══</span></span>
<span style="display:flex"><span>Platform: ios Deployment: Staging Server: https://maru-patch.fly.dev</span></span>
<span style="display:flex"><span>Target binary version: 2.5.1</span></span>
<span style="display:flex"><span>ios: bundling and releasing to maru-ios / Staging...</span></span>
<span style="display:flex"><span>✓ ios: release v1 published (49s)</span></span>
</code>
</pre>
</div>
<p>The majority of the 49 seconds consumed was for packaging the JavaScript files. Subsequently, I simulated phone behavior, executing a request without credentials:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ curl -sI $BASE/dep_5b0e…/2.5.1/manifest.json</span></span>
<span style="display:flex"><span>HTTP/1.1 200 OK</span></span>
<span style="display:flex"><span>Cache-Control: no-cache, must-revalidate</span></span>
<span style="display:flex"><span>Content-Type: application/json</span></span>
<span style="display:flex"><span>$ curl -sI $BASE/dep_5b0e…/2.5.1/7c2e91ab…/bundle.tar.zst</span></span>
<span style="display:flex"><span>HTTP/1.1 200 OK</span></span>
<span style="display:flex"><span>Cache-Control: public, max-age=31536000, immutable</span></span>
<span style="display:flex"><span>Content-Length: 7301055</span></span>
</code>
</pre>
</div>
<p>The above exchange demonstrates preferred caching practices, with manifests revalidated on each check to ensure prompt rollbacks upon user report, while bundle URLs containing their respective hashes are safe for long-term caching.</p>
<figure class="size- align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/dash-staging-releases.png" alt="The Staging deployment on the Fly server." title=""/>
<figcaption>The Staging deployment on the Fly server.</figcaption>
</figure>
<h2 id="code-signing-from-the-first-binary">Initial Code Signing</h2>
<p>Newly created applications do not necessitate <a href="https://patch.codemagic.io/docs/using-patch/security">signed releases</a>, and the publication script only executes a signing process when provided with a private key. Last month, I established the code signing and tested it locally but had not yet decided to deploy it in production. The server setup made this decision clear; once an app enforces signing, unauthorized tokens cannot disseminate code to users' devices. Furthermore, the public key must reside in the initial binary communicating with the server, as it cannot be retrofitted into an app over the air. No binary had been constructed against the new server previously, thus necessitating an immediate build:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ openssl genrsa -out patch-production-private.pem 2048</span></span>
<span style="display:flex"><span>$ openssl rsa -in patch-production-private.pem -pubout
</span></span>
<span style="display:flex"><span> -out patch-production-public.pem</span></span>
<span style="display:flex"><span>$ cmpatch app setting --app maru-ios --require-code-signing=true</span></span>
<span style="display:flex"><span>$ cmpatch app setting --app maru-android --require-code-signing=true</span></span>
</code>
</pre>
</div>
<p>The subsequent publication was attempted without a private key:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>This app requires signed releases. Rebuild the release with a code-signing private key, for example: `cmpatch bundle --private-key-path <pem>` …</span></span>
</code>
</pre>
</div>
<p>The next attempt successfully utilized the private key:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>Signing: patch-production-private.pem</span></span>
<span style="display:flex"><span>✓ ios: release v2 published (40s)</span></span>
</code>
</pre>
</div>
<figure class="size- align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/dash-release-v2.png" alt="Release v2: signed, and the worker job that published it." title=""/>
<figcaption>Release v2: signed, and the worker job that published it.</figcaption>
</figure>
<p>The public key is incorporated into build profiles in a single base64 string alongside the server URL, download URL, and deployment keys. Safeguarding the private key within a password manager and ensuring it's exclusive to CI as a secret is crucial.</p>
<figure class="size- align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/dash-server.png" alt="The status page checks the database, storage, and both public URLs." title=""/>
<figcaption>The status page checks the database, storage, and both public URLs.</figcaption>
</figure>
<h2 id="over-the-air-before-the-store">Over-the-Air Updates Prior to Store Release</h2>
<p>OTAs are frequently discussed in contexts surrounding fixes for apps already distributed to end-users, but their utility before release is equally notable, enabling seamless code deployment to test devices without a full native build.</p>
<p>For teams like mine, this usage represents a substantial cost reduction. Previously, any update request from product leaders necessitated not only a native build—approximately a quarter-hour on my machine—but also further actions involving uploads, TestFlight processing, and subsequent installations. This led to a backlog of days before receiving critical feedback on simple changes.</p>
<p>With Patch, I established a third deployment parallel to Staging and Production to accommodate testers’ builds:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch deployment create --app maru-ios --name Development</span></span>
</code>
</pre>
</div>
<p>Building the tester’s app initially involved the new deployment key, resulting in maru version 2.5.7, marking its first integrated implementation of Patch. Ensuring the correct values exported into the binary was vital, as a missing build without those values would result in silence from OTA updates:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>CodemagicPatchApiUrl https://maru-patch.fly.dev</span></span>
<span style="display:flex"><span>CodemagicPatchDeploymentKey dep_e27c9b5a10f3486d9a1b4c8e6f02d7a5</span></span>
<span style="display:flex"><span>CodemagicPatchDownloadBaseUrl https://maru-patch-artifacts.fly.storage.tigris.dev</span></span>
<span style="display:flex"><span>CodemagicPatchPublicKey MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…</span></span>
<span style="display:flex"><span>CFBundleShortVersionString 2.5.7</span></span>
</code>
</pre>
</div>
<p>This initial build took 13 minutes and 37 seconds, with subsequent builds approaching the 18-minute mark from a cold cache.</p>
<figure class="size-small align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/app-before-update.png" alt="The tester's build, as installed: Join the community." title=""/>
<figcaption>The tester's build, as installed: Join the community.</figcaption>
</figure>
<p>The subsequent textual change—switching "Join the community" to "Join your neighbours on maru"—followed suit, leading to a publication directed towards Development:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>$ ./scripts/publish-patch-update.sh ios --development
</span></span>
<span style="display:flex"><span> --notes "Copy change for testers: Join your neighbours on maru"</span></span>
<span style="display:flex"><span>Platform: ios Deployment: Development Server: https://maru-patch.fly.dev</span></span>
<span style="display:flex"><span>Signing: patch-production-private.pem</span></span>
<span style="display:flex"><span>Target binary version: 2.5.7</span></span>
<span style="display:flex"><span>✓ ios: release v1 published (94s)</span></span>
</code>
</pre>
</div>
<p>This release took longer than the typical 40-50 seconds due to Metro handling a fresh checkout. Upon reopening the tester's app, the update presentation occurred almost instantaneously, restarting with the new text in just 33 seconds—no native build required.</p>
<figure class="size-small align-center">
<img class="lazyload" src="https://blog.codemagic.io/uploads/2026/11/app-after-update.png" alt="After the restart: Join your neighbours on maru." title=""/>
<figcaption>After the restart: Join your neighbours on maru.</figcaption>
</figure>
<table>
<thead>
<tr>
<th>Deploying JavaScript Changes to a Tester Device</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Native Release build, warm cache</td>
<td>13 min 37 s</td>
</tr>
<tr>
<td>Native Release build, cold cache</td>
<td>18 min 24 s</td>
</tr>
<tr>
<td>… then upload, TestFlight processing, install</td>
<td>minutes additional; human involvement required</td>
</tr>
<tr>
<td>Patch: publish to Development (includes bundling, signing, upload, publishing)</td>
<td>40–95 s</td>
</tr>
<tr>
<td>Patch: phone receiving update following application restart</td>
<td>33 s</td>
</tr>
</tbody>
</table>
<p>I gleaned several insights from this process.</p>
<p>The initial publication attempt failed to update due to my app's settings rather than any fault with Patch itself. maru initiates update checks upon launch and again when it returns to the foreground; however, this occurs at a maximum frequency of once every fifteen minutes, a reasonable cadence for regular users. Unfortunately, testers waiting on feedback found themselves at an impasse, unable to collect updates for fifteen minutes. For quicker results, testers can achieve this by fully closing and reopening the app. Setting tailored profiles for test builds yields a reduced interval for updates or allows for continuous checks.</p>
<p>Only JavaScript changes can be transmitted via OTA. The comprehensive list of what is transferable can be found in the documentation titled <strong>React Native OTA Updates</strong>. The CLI <a href="https://patch.codemagic.io/docs/using-patch/fingerprinting">fingerprints</a> the native project, and any release failing to align with the target binary results in a 409 response from the server. This method ensures that changes that necessitate native module addition mandate a fresh build, which is the intended behavior. However, most feedback during testing pertains to minor copy edits or layout adjustments.</p>
<p>Code signing is managed on a per-app basis versus per-deployment. I enabled signing for <code>maru-ios</code>, meaning Development releases utilize the same production key in the process. This is practical since the key resides on the publishing machine; alternatively, if developers require publishing access to Development without a shared key, establishing a separate app for testers, such as <code>maru-ios-dev</code>, with its unique key is advisable. This approach does have its trade-offs, as releases cannot be promoted between applications.</p>
<p>Promotion remains a highlight of the system. The tester build corresponds directly to the store build version, so an approved release from testing is in essence a bundle already available on the server. Previously, when promoting from Staging to Production with maru, the identical package was utilized, preserving the hash and notes without the need for an additional build cycle. Each release in Development retains a Promote function, enabling users to receive precisely the package that passed the tester's scrutiny without clicking through to a rebuild.</p>
<h2 id="moving-maru-itself">Transition of maru Deployment</h2>
<p>The server represents only part of the complete task. Users running versions of maru at 2.5.6 or earlier employ expo-updates and source updates from my prior Django server, making it impossible for Patch to reach them. Shifting maru necessitated the store build which incorporated the Patch SDK, aligning it to version 2.5.7.</p>
<p>The Patch integration stemmed from a branch off of version 2.5.1, which underwent three weeks of divergence from the main branch. Merging back introduced conflicts across seven files, all related to the update framework: removal of <code>expo-updates</code> from <code>package.json</code>, adjustments in <code>app.config.js</code>, modifications in <code>Expo.plist</code> and Android resource, test mockups, and revisions in both lockfiles, which I consequently regenerated instead of merging. No discrepancies were found within the app's specific screens. Furthermore, I upgraded the SDK to transition from 0.3.0 to 0.5.0 to correspond with the server, passing type-checking without additional alterations.</p>
<p>The sole disruption that would have materialized from the conflicts was a file introduced into the main branch after the fork; it retrieves the expo-updates runtime version to signal to my API which build is being utilized. By eliminating the package reference, Metro would have ultimately failed to compile. The new build headers now utilize <code>patch:2.5.7</code>, which turns out to be advantageous: my API's records can now identify which builds remain on expo-updates, indicating when the last instance can be deprecated and my previous update server disabled. Until such a time, that server will remain operational.</p>
<h2 id="two-weeks-in-production">Initial Two-Week Production Review</h2>
<p>On October 5, version 2.5.7 was submitted to both stores. Prior to submission, I verified that the Patch-related values were embedded correctly within the built binaries, utilizing <code>plutil</code> on the <code>Info.plist</code> of the <code>.ipa</code> and conducting a simple resource search within the Android bundle for the deployment key. Both were confirmed to contain the Production keys while lacking any traces of Staging.</p>
<p>The first published attempt to Production met with rejection due to unsigned application requirements; a mistake from running the publication script without the relevant key, highlighting the security measure’s effectiveness. Two factors compounded this complication: the utilization of <code>--dry-run</code> which bypassed server checks for requirements; and possessing two distinct private keys on disk—an evaluation key alongside a production key—without a clear guideline within the CLI regarding which aligned with the public key embedded in 2.5.7. A manual comparison with <code>openssl</code> was necessary.</p>
<p>Once the appropriate key was deployed, release version 1 successfully launched to both platforms at 10% capacity—this wasn’t a mere test; rather, it resolved a genuine issue of displaying Korean text in italics on iPhone devices due to absence of appropriate font. App Store Connect expedites all uploaded builds to internal testers prior to review—within mere minutes post approval, the TestFlight iPhone running 2.5.7 downloaded the release: 1 ready, 0 failures. Adjustment to 100% was executed just three minutes following this confirmation.</p>
<p>Over the ensuing eight days, I delivered about ten production releases across versions 2.5.7, 2.5.9, and 2.5.10. These incremental adjustments, which would conventionally have stymied release cycles awaiting store evaluations, streamlined the deployment process. One publish did encounter an HTTP 500 error with the message <code>Connection terminated unexpectedly</code>, predominantly due to a dropped connection to the database on the server side; however, a subsequent repeat of identical commands succeeded without issue. Additionally, the fingerprint verification correctly flagged two releases whose native projects diverged from the binaries they were targeting, exemplifying the system's integral checks.</p>
<p>The first native upgrade post-transition, featuring Expo SDK 58 as version 2.6.0, introduced a challenge I would have ideally encountered discreetly: during the Android release build process, none of the four Patch values were incorporated within. Enhanced string shrinkage protocols within the Android Gradle Plugin 9 imposed stricter regulations than prior standards, which negated the direct reading of configuration via resource names, prompting removal by the shrinker. Consequently, a missing value interpreted as blank caused the SDK to disable its OTA capabilities—a scenario that would remain silent, where Android version 2.6.0 would install and function yet never receive updates. Implementation of a keep rule for <code>@string/CodemagicPatch*</code> rectified the issue, and since then, the React Native SDK at version 0.6.0 (issued on October 28) includes that rule natively, while also alerting a missing deployment key on Android. Ensure you routinely verify values contained within every binary release, beyond the initial build.</p>
<h2 id="upgrades-and-backups">Managing Upgrades and Backups</h2>
<p>Maru's homegrown server upgrades in alignment with the setup I initiated: through my own script that clones the updated release tag and executes <code>fly deploy</code>. However, for a wizard installation, a unified command accomplishes this from the machine that performed the setup:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch selfhost upgrade</span></span>
</code>
</pre>
</div>
<p>This command builds the latest server release on Fly and subsequently deploys it with the app's current configuration. Prior to deployment, it snapshots the database volume established during the wizard's database setup phase and generates a folder that contains a <code>ROLLBACK.md</code> under <code>codemagic-patch-backups/maru-demo-patch.fly.dev/</code>. The command does not automatically revert; should you need rollback, the necessary procedures are archived in that document with prefilled snapshot and image instructions. If you are managing your own database, ensure to take a backup prior to continuing.</p>
<p>As of this writing, 0.8.0 remains the latest release, leaving no immediate upgrades necessary. However, testing the backup feature found it to be equally straightforward. After signing into the new server and generating one app, <code>maru-ios</code>:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>$ cmpatch selfhost backup</span></span>
<span style="display:flex"><span>◇ Checking maru-demo-patch on Fly [2s]</span></span>
<span style="display:flex"><span>◇ Checking maru-demo-patch-db [0s]</span></span>
<span style="display:flex"><span>◇ Dumping the database on 8d96e69a107078 [6s]</span></span>
<span style="display:flex"><span>◇ Downloading the dump [3s]</span></span>
<span style="display:flex"><span>◇ Saving maru-demo-patch's release config [4s]</span></span>
<span style="display:flex"><span>└ Backup complete.</span></span>
<span style="display:flex"><span>Backup saved to this machine (fly/1, database):</span></span>
<span style="display:flex"><span> codemagic-patch-backups/maru-demo-patch.fly.dev/maru-demo-patch-2026-10-28T16-05-48</span></span>
<span style="display:flex"><span> Database: maru-demo-patch-db (1 MB, 19 migrations, Postgres 16.15)</span></span>
<span style="display:flex"><span> Server: codemagic-patch-server-v0.8.0 (registry.fly.io/maru-demo-patch:deployment-01M61KP4ZP15RY3MQ8B6H6CJWB)</span></span>
<span style="display:flex"><span>Restore it with: cmpatch selfhost restore maru-demo-patch-2026-10-28T16-05-48</span></span>
</code>
</pre>
</div>
<p>The database dump occurs within its Postgres machine while Patch remains operational, generating three files in the originating directory: the database dump, the app's <code>fly.toml</code>, and a manifest. It’s important noting that the release files in the buckets or application secrets do not get included; hence, secure copies to a separate location outside of your machine and storing secrets in a password manager is recommended.</p>
<p>In testing a restore operation's functionality, I created a separate app called <code>maru-android</code>. Following that, I proceeded with the restore:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-text" data-lang="text">
<span style="display:flex"><span>$ cmpatch selfhost restore</span></span>
<span style="display:flex"><span>◇ Which backup do you want to restore?</span></span>
<span style="display:flex"><span>│ maru-demo-patch-2026-10-28T16-05-48 (2026-10-28T16:05:48.067Z, database, codemagic-patch-server-v0.8.0)</span></span>
<span style="display:flex"><span>◇ Type "restore" to continue</span></span>
<span style="display:flex"><span>│ restore</span></span>
<span style="display:flex"><span>◇ Uploading dump to 8d96e69a107078 [7s]</span></span>
<span style="display:flex"><span>◇ Suspending maru-demo-patch (no machines running while the database is replaced) [4s]</span></span>
<span style="display:flex"><span>◇ Snapshotting vol_40o33wwe9y9pgmp4 in maru-demo-patch-db [51s]</span></span>
<span style="display:flex"><span>◇ Replacing the database [3s]</span></span>
<span style="display:flex"><span>◇ Restoring backup from 2026-10-28T16:05:48.067Z [3s]</span></span>
<span style="display:flex"><span>◇ Verifying restored database [3s]</span></span>
<span style="display:flex"><span>◇ Redeploying codemagic-patch-server-v0.8.0 onto the restored database [31s]</span></span>
<span style="display:flex"><span>◇ Awaiting verification from https://maru-demo-patch.fly.dev/health/ready [11s]</span></span>
<span style="display:flex"><span>Your data is restored. Sign in again with `cmpatch login`, then publish a release to confirm end-to-end functionality.</span></span>
</code>
</pre>
</div>
<p>The <code>maru-android</code> instance has been removed and <code>maru-ios</code> restored as expected. Prior to confirming the restore by typing <code>restore</code>, it indicates impending changes, specifically that anything published post-backup would need to be redacted, the files in the buckets will not revert, and both dashboard and API functionalities will be suspended during the process while phones continue retrieving updates from storage. The initial snapshot of the database occurs prior to the restoration action, allowing for potential rollback should issues arise. The process encompassed approximately two and a half minutes, majorly influenced by the snapshot and server reintegration. Post-operation, the CLI was signed out, a detail communicated in the closing statement.</p>
<p>To execute these commands from an alternative machine, ensure to record the setup on that device first, which installs nothing on Fly:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>$ cmpatch selfhost install --fly --register --company "Maru Demo"</span></span>
</code>
</pre>
</div>
<h2 id="what-it-costs">Cost Overview</h2>
<table>
<thead>
<tr>
<th>Service</th>
<th>Monthly Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td>Fly machine for Patch, shared CPU, 512 MB, suspended when idle</td>
<td>Under US$5</td>
</tr>
<tr>
<td>Postgres, created by the wizard: similar shared machine, also suspended, with a 1 GB volume</td>
<td>Under US$5, plus 15 cents per volume</td>
</tr>
<tr>
<td>Postgres for maru: utilizing an existing cluster</td>
<td>No additional cost</td>
</tr>
<tr>
<td>Tigris storage and requests</td>
<td>Minimal cents</td>
</tr>
</tbody>
</table>
<p>The aforementioned fees represent maximum potential charges, assuming uninterrupted machine operation. The least expensive rate offered by Fly is US$3.69 per month in their budget regions, with US$4.83 applicable in Tokyo—site of my wizard execution. Fly calculates charges based on seconds of usage, with machines incurring costs solely for their disk space at a rate of 15 cents per GB monthly while in a dormant state. As phones fetch updates directly from Tigris, the machines primarily activate upon publishing events, dashboard access, or report transmissions from phones. Consequently, with minimal active time, the entire setup typically incurs only about 25 cents per month, largely for the database volume. Should the machines remain active approximately two hours daily, costs approach one dollar. Fly permits daily volume snapshots devoid of charge for up to 10 GB, and Tigris' free tier adequately accommodates small application updates (5 GB storage, 10,000 writes, and 100,000 reads monthly).</p>
<p>Previously, my expo-updates server incurred about one dollar monthly, attributed to basic rows in a database alongside a few files in an existing bucket. While Patch and its functionalities incur slightly higher expenses, the investment yields a richly featured dashboard, staged releases, crash rollback availability, installation metrics, signing capabilities, plus a codebase free from maintenance burdens.</p>
<h2 id="where-this-leaves-things">Current System State</h2>
<p>When I originally structured maru's server, it entailed an afternoon of work and research—discovering the need for a second bucket due to Tigris's lack of policy support, navigating download addresses successfully as hostnames, optimizing for suspension rather than halting, and adapting health checks to disregard the database. The wizard now efficiently encapsulates all steps in a single command, affirming integrity post-setup prior to entrusting addresses. The only ongoing requirement performed via browser engagement revolves around creating the sign-in application, as GitHub does not support automated setups through command lines. Following this, one integral check awaiting completion involves verifying values within the inaugural binary produced.</p>
<p>The most salient design feature remains that devices never request the API for updates. This attribute accounts for the ability of the server to function effectively on one of Fly's smallest machines, ensures uninterrupted access to previously published releases even if the server experiences downtime, and enables swift access to changes for testers shortly following publication.</p>
<p>A remaining area of concern pertains to accommodating sign-in processes for stakeholders utilizing platforms other than GitHub, GitLab, or Bitbucket.</p>
<p>Should you wish to experiment with Patch, the <a href="https://patch.codemagic.io/docs/install#fly">installation documentation</a> outlines the Fly setup, while the <a href="https://patch.codemagic.io/docs/">Patch documentation</a> initiates with a local evaluation stack you can run on a laptop for testing before full deployment.</p>
<p>Since October 5, maru has been operational with Patch in a production setting, racking up over twenty releases per platform by the conclusion of the month. Future articles will explore ongoing insights into rollout procedures, rollback strategies tailored for increased user volumes, and informative reflections based on extended operational experience in this setup.</p>
<h2 id="faq">Frequently Asked Questions</h2>
<h3 id="how-do-i-install-codemagic-patch-on-flyio">How can I install Codemagic Patch on Fly.io?</h3>
<p>Begin by installing the CLI and then execute the Fly installer on your local machine:</p>
<div class="highlight">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<code class="language-bash" data-lang="bash">
<span style="display:flex"><span>npm install -g @codemagic/patch-cli</span></span>
<span style="display:flex"><span>cmpatch selfhost install --fly</span></span>
</code>
</pre>
</div>
<p>This process will prompt you for a company name, a preferred region, and ask about your existing Postgres database status. It will establish the Fly app, construct two Tigris buckets, and create a Postgres machine if needed. Sign-ins to Fly and Tigris through your browser, along with the creation of a GitHub, GitLab, or Bitbucket sign-in application, are also required.</p>
<h3 id="how-much-does-codemagic-patch-on-flyio-cost">What are the costs associated with Codemagic Patch on Fly.io?</h3>
<p>A shared 512 MB machine, which pauses when not in use, costs under US$5 monthly. A Postgres machine initiated by the wizard incurs similar charges, plus 15 cents per GB volume. When inactive, the comprehensive installation may total around 25 cents per month. Tigris offers sufficient free storage for minor applications. If utilizing a PostgreSQL cluster you presently maintain, you will not incur extra expenses.</p>
<h3 id="do-phones-talk-to-the-patch-server-to-check-for-updates">Do phones communicate with the Patch server for update checks?</h3>
<p>No, phones download manifests and bundles straight from the public Tigris bucket, contacting the server only to report installation results. Consequently, if the machine experiences sleep or outages, phones can still access the latest release previously published.</p>
Chief Editor of Quincy City Beat — Quincy, MA's independent investigative newspaper covering Greater Boston and the entire Commonwealth of Massachusetts with fearless public accountability and free access for all readers.










Discussion about this post