Skip to main content

Command Palette

Search for a command to run...

Deploying to Miget from a private GHCR: an AI agent did the whole thing

Updated
11 min readView as Markdown
Deploying to Miget from a private GHCR: an AI agent did the whole thing
K
Engineering Team Leader with 8+ years building backend systems in Java/Kotlin & Spring Boot. I write about engineering, leading teams, and building products on the side.

Hello there!

A small disclaimer before we start: Miget was built by my friend. It's a PaaS for deploying containerized apps. I wanted to try it on my own project for some time, so I did, and here I want to share how it went.

To be honest, I didn't expect it to be this easy. The REST API is so clean that an AI agent did the whole deploy on its own, from zero to a running stack. It hit a few problems on the way and solved them by itself. Let me show you how it looked. Ready? Let's go!


What this was

The goal was a "walking skeleton" - prove the whole deploy chain before writing a single feature. Two container images from GitHub Container Registry (GHCR): a Spring Boot API and a Next.js web frontend. Pull them onto Miget, point the API at PostgreSQL, and get GET /health to return UP.

That's it. No features. No real users. Just proof that the whole chain works: GHCR pulls from a private repo, containers start, Flyway migrates the real database, HTTP responds.

The repo is private, so the GHCR packages are private too. That adds a few extra steps compared to a public image.


How the agent drove it

I started by reading the Miget REST API myself - checked the OpenAPI spec at https://app.miget.com/docs/openapi.json, fired a few read-only curl calls to see what's there. Then I installed Miget's own agent skill:

npx skills add migetapp/agent-skills

That gives you a miget-api skill with a SKILL.md that has the exact schemas. After that I handed the task to the agent: find the account, create the registry credential, create the apps with the credential attached, set the env vars, deploy, poll for state, hit the health endpoint.

The agent ran the full sequence. I watched. It hit walls. It worked around them. I did not intervene. That's the part worth writing about.


Setup / prerequisites

  • Miget API token from https://app.miget.com/my_account#api_tokens. All requests go to base https://app.miget.com/api/v1 with header Authorization: Bearer <MIGET_TOKEN>.

  • Images on GHCR. The CI (container.yml) only pushes on push to main, and it tags by commit sha - sha-<commit>. The feature branch had to be merged to main first to get ghcr.io/<owner>/<repo>/{api,web}:sha-<commit>. Deploy by the sha tag, not latest.

  • Miget agent skill (optional but useful): npx skills add migetapp/agent-skills. I read the API first, then installed this for the exact request/response schemas.


Step 0 - Discover the account

The agent started read-only. No writes until we knew what we were working with.

B=https://app.miget.com/api/v1 ; T=<MIGET_TOKEN>

curl -s -H "Authorization: Bearer \(T" "\)B/projects/<PROJECT>"   # confirm token + project
curl -s -H "Authorization: Bearer \(T" "\)B/resources"            # compute node
curl -s -H "Authorization: Bearer \(T" "\)B/services"             # existing Postgres service
curl -s -H "Authorization: Bearer \(T" "\)B/apps"                 # existing apps

What came back shaped everything that followed:

  • Project was empty - zero apps.

  • One compute node migetrmb: 1 GB RAM total, about 256 MB already used by the Postgres service. That leaves roughly 768 MB free.

  • An existing standalone Postgres service (postgres-service-d7eqa), state healthy. GET /services/{id} returns connection_details with both an internal host (.migetapp.internal - for apps on the same node) and an external host (.onmiget.com - reachable from a laptop), plus the database name, user, password, and a ready-to-run psql command.

RAM budget noted. Internal vs external host noted. Moving on.


Step 1 - First deploy attempt (and the first failure)

The agent created both apps with the container_registry deployment method, set env vars, and deployed.

curl -s -X POST "\(B/apps" -H "Authorization: Bearer \)T" -H 'Content-Type: application/json' -d '{
  "name":"inbox-api","label":"Inbox API","project_id":"<PROJECT>","resource_id":"<NODE>",
  "builder":"dockerfile","ram_size":512,"cpu_size":0.5,
  "deployment_method":"container_registry",
  "deployment_config":{"image_url":"ghcr.io/<owner>/<repo>/api","tag":"sha-<commit>"}}'

curl -s -X POST "\(B/apps/<API_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'

Result: state: failed in about 11 seconds. logs_stored_at: null.

The agent read this correctly. For a container_registry deploy, a fast failure with no stored logs means the container never started - which means the image pull failed. The repo is private. No registry credential was attached. Miget couldn't authenticate to GHCR.

Fast fail, no logs - pull problem, not a runtime problem. That's the pattern to remember.


Step 2 - Create a registry credential

To pull from a private GHCR repo you need a GitHub PAT with read:packages. Then you register it as a Miget credential. The endpoint is in the OpenAPI spec, not in the basic guide:

curl -s -X POST "$B/container_registry_credentials" \
  -H "Authorization: Bearer $T" \
  -H 'Content-Type: application/json' \
  -d '{"name":"ghcr-inbox","registry":"github","username":"<github-user>","token":"<GHCR_PAT>"}'
# → { "uuid": "<CRED_UUID>" }

Miget validates the credential live on create. If the PAT is wrong, you hear about it immediately.

The registry field accepts: docker_hub, github, gitlab, aws_ecr, azure, digitalocean, quay, generic.


Step 3 - The credential_id quirk (delete and recreate)

With the credential UUID in hand, the agent tried to attach it to the existing apps via PUT /apps/{uuid}. HTTP 200 came back. But the credential was silently dropped.

Reading the OpenAPI more carefully: credential_id lives inside deployment_config and is only accepted at app creation (POST /apps). It is not accepted on PUT. It is not accepted on the deploy call. There is no way to add it after creation.

So the agent deleted both apps and recreated them with credential_id in deployment_config from the start.

curl -s -X DELETE "\(B/apps/<OLD_APP>" -H "Authorization: Bearer \)T"

curl -s -X POST "\(B/apps" -H "Authorization: Bearer \)T" -H 'Content-Type: application/json' -d '{
  "name":"inbox-api","label":"Inbox API","project_id":"<PROJECT>","resource_id":"<NODE>",
  "builder":"dockerfile","ram_size":512,"cpu_size":0.5,
  "deployment_method":"container_registry",
  "deployment_config":{
    "type":"container_registry",
    "image_url":"ghcr.io/<owner>/<repo>/api",
    "tag":"sha-<commit>",
    "credential_id":"<CRED_UUID>"
  }}'

One more thing: the API does not echo credential_id back in GET or POST responses. After creation, the app resource looks like the field was never set. But it was applied - the private pull succeeds. You just can't confirm it from the API response alone. The web UI is more helpful here: it shows a dropdown ("Select a credential…" with "Leave empty if your image is publicly accessible"), so you can check visually.


Step 4 - Port 5000 and the database

Port 5000. Miget serves every app's public *.migetapp.com URL from container port 5000. Fixed. The Spring Boot image listens on 8080 and the Next.js image on 3000. The fix is an env var:

  • Spring Boot: SERVER_PORT=5000

  • Next.js: PORT=5000

Database - CREATEDB denied. The agent checked whether the Postgres service user could create a new database (inbox). It cannot:

PGPASSWORD=<pw> psql -h <svc>.db.<region>.onmiget.com -U <user> <defaultdb> \
  -c "CREATE DATABASE inbox;"
# → ERROR: permission denied to create database

The workaround: reuse the default database that already exists and isolate with Flyway schemas (shared, inbox, ai, crm). Good enough for the skeleton.

The env vars for the API app, set one-per-key via POST /apps/<API_APP>/vars:

SPRING_PROFILES_ACTIVE     = prod
DATABASE_URL               = jdbc:postgresql://<svc>.<node>.<region>.migetapp.internal:5432/<defaultdb>
SPRING_DATASOURCE_USERNAME = <user from connection_details.internal>
SPRING_DATASOURCE_PASSWORD = <password from connection_details.internal>
SERVER_PORT                = 5000

For the web app:

PORT = 5000

One note on SPRING_PROFILES_ACTIVE: the Spring image bakes no profile or credentials and defaults to a local profile pointing at localhost. Without SPRING_PROFILES_ACTIVE=prod, the container starts fine but silently ignores DATABASE_URL. That env var is not optional.


Step 5 - Deploy and verify

curl -s -X POST "\(B/apps/<API_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'
curl -s -X POST "\(B/apps/<WEB_APP>/deploy" -H "Authorization: Bearer \)T" -d '{}'

Poll for state:

# app-level state
curl -s -H "Authorization: Bearer \(T" "\)B/apps/<API_APP>"              # → state: running
# deployment-level state
curl -s -H "Authorization: Bearer \(T" "\)B/apps/<API_APP>/deployments"  # → state: completed

Public URLs follow the pattern https://<app-name>.<region>.migetapp.com - the app name gets a random suffix appended, so inbox-api becomes something like inbox-api-y3c3k.

GET https://inbox-api-y3c3k.eu-east-1.migetapp.com/health  →  200  {"status":"UP"}
GET https://inbox-web-rhtqc.eu-east-1.migetapp.com/en      →  200

The web returned a transient 502 right after deploy - the node was still booting. A minute later, 200.

/health returning UP is the real signal. The Spring health endpoint includes the readiness group, which checks the database. A single 200 proves the whole chain: GHCR pull worked, container started, Flyway migrated the real Postgres, HTTP is responding.


Step 6 - Logs live somewhere else

When the agent went to check the deployment logs, they were empty:

GET /apps/<API_APP>/deployments/<DEPLOY_ID>/logs
# → []

This is by design. For container_registry deploys there is no build phase, so there are no build logs. Runtime logs go to a separate Loki instance at https://metrics.miget.com, using the same Bearer token.

# find your service label first
curl -s -G https://metrics.miget.com/loki/api/v1/label/service_name/values \
  -H "Authorization: Bearer $T"

# then query the last 30 minutes of logs
curl -s -G https://metrics.miget.com/loki/api/v1/query_range \
  -H "Authorization: Bearer $T" \
  --data-urlencode 'query={service_name="inbox-api"}' \
  --data-urlencode "start=\((( (\)(date +%s)-1800)*1000000000 ))" \
  --data-urlencode "end=\((( \)(date +%s)*1000000000 ))" \
  --data-urlencode 'limit=400' \
  --data-urlencode 'direction=forward'

Useful LogQL filters: |= "error", |~ "timeout|refused", != "/health" (to stop health-check noise), | json | level="error". The Miget docs for this are at https://docs.miget.com/monitoring/logs and the UI's Monitoring → Logs section does the same thing.


What landed

The final state after the agent finished:

  • API: https://inbox-api-y3c3k.eu-east-1.migetapp.com - image api:sha-472a9ea, /health returns UP

  • Web: https://inbox-web-rhtqc.eu-east-1.migetapp.com/en - 200

  • Database: reused postgres-service-d7eqa, default database, 4 Flyway schemas

  • Private pull: via Miget registry credential ghcr-inbox


Gotchas

There were 8 things that were not obvious. The agent figured all of them out on its own. I'm listing them here so you don't have to.

  1. Private GHCR needs a registry credential. Fast fail with no logs is the sign.

  2. credential_id only accepted on app creation. PUT silently drops it. The fix is delete and recreate. The credential works even though the API doesn't echo it back.

  3. Port 5000 is fixed. Override with SERVER_PORT=5000 / PORT=5000.

  4. CREATEDB is denied for the service user. Reuse the default database, isolate with schemas.

  5. Internal vs external DB host. Apps on the node use .migetapp.internal. Your laptop uses .onmiget.com.

  6. Runtime logs are in Loki at metrics.miget.com. Build logs are empty for registry deploys.

  7. RAM is tight. 1 GB node, Postgres eats ~256 MB, API gets 512 MB, web gets 256 MB. That's exactly the budget - zero headroom.

  8. CI images only publish from main. Tagged sha-<commit>. Deploy by the sha tag, not latest.


What's not done yet

The skeleton proves connectivity. It does not prove the product.

  • Web is not wired to the API. NEXT_PUBLIC_API_BASE_URL is baked into the Next.js image at build time. Pointing the frontend at the deployed API URL requires a rebuild with the production URL. Right now each piece starts and responds - they are not yet talking to each other.

  • Shared database. No dedicated inbox database - schema isolation only. Fine for now.

  • No headroom on the node. Adding any more services will require a bigger plan first.

  • Deploy by digest, not by sha tag. The sha tag is human-friendly but not reproducibility-hardened. Next step is deploying by image digest.

  • Rotate the tokens. The Miget API token and the GHCR PAT used during setup should be rotated now that the skeleton is stable.


On cost

It's worth mentioning for anyone running small projects: two private containers plus a managed Postgres on a single 1vcpu and 1 GB RAM node on Miget costs a lot less than the same setup on the big clouds. For a walking skeleton in a bootstrapped project, that gap matters. I won't put a number here because pricing changes, but it is worth checking against what you're paying now.


That's the full walkthrough. The skeleton is running. The next step is wiring the frontend to the API and getting the first real user flow working end-to-end. We will see how that goes.

Let me know what you think - especially if you ran into different walls on Miget.

More from this blog