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

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:

```shell
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.

```shell
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.

```shell
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:

```shell
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.

```shell
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:

```shell
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`:

```shell
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:

```shell
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

```shell
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:

```shell
# 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`.

```shell
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:

```shell
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.

```shell
# 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.
