The Problem

You run multiple self-hosted apps on a VPS. Each app lives in a container. You build images locally, but storing them is a pain. Docker Hub's free tier gives you one private repo. GitHub Container Registry works but feels tacked-on. You could self-host a registry on your VPS, but that means managing another service, SSL certs, storage, and backups.

There's a better option hiding in plain sight: GitLab's free container registry.

This guide uses Docker commands throughout since that's what most readers have installed. If you're running Podman instead, the good news is GitLab's registry speaks the standard Docker Registry API (OCI-conformant), so podman login, podman build, podman push, and podman pull work as drop-in replacements for every command below — same flags, same syntax.


What You Get for Free

GitLab's Container Registry is included in every free-tier project. Here's the good news:

  • No storage limit on container images — the 10 GiB free-tier cap only applies to Git repos and LFS, not container registry storage
  • Private by default — images are only accessible to project members
  • Web UI — search, filter, delete images and tags from the browser
  • Cleanup policies — auto-expire old tags on a schedule
  • Standard Docker API — works with docker login, docker push, docker pull like any other registry
  • OCI conformant — Docker V2 and OCI image manifests supported

The catch: 400 compute minutes/month for CI/CD pipelines. But if you're pushing manually (which this guide covers), that doesn't apply to you.


Setup

1. Create a GitLab Project

Go to gitlab.com → New project → Create blank project. Name it whatever you want. The project path becomes your image namespace:

registry.gitlab.com/your-username/your-project

2. Enable Container Registry

By default it's enabled. Verify:

  1. Settings → General → Visibility, project features, permissions
  2. Container Registry should be set to Enabled (or Private if you want only maintainers+ to see it)

3. Create a Deploy Token

A Deploy Token lets your VPS authenticate without using your personal account credentials.

  1. Settings → Repository → Deploy Tokens
  2. Create deploy token:
    • Name: vps-pull
    • Scopes: read_registry
    • Expiration: set as needed (or leave blank)
  3. Save the username and token — you'll only see the token once

For pushing images from your dev machine, use a Personal Access Token (PAT):

  1. Profile → Access Tokens → Add new token (see doc guides)
    • Scopes: write_registry, read_registry
    • Expiration: set as needed

 


Build and Push

Authenticate

docker login registry.gitlab.com
# Username: your-username
# Password: your-PAT (not your account password)

Build Your Image

The image name must follow the registry path convention:

registry.gitlab.com/<namespace>/<project>[/<optional-path>]:<tag>

For a Django app:

docker build -t registry.gitlab.com/your-username/my-django-app:v1.0.0 .

Push

docker push registry.gitlab.com/your-username/my-django-app:v1.0.0

That's it. The image is now stored in GitLab's registry, tied to your project.

Tagging Strategy

For a solo project, keep it simple:

Tag When to Use
v1.0.0, v1.1.3 Semver releases — stable, deployable
abc1234 Git commit SHA — traceable, ephemeral
latest Only for the most recent stable push

Avoid using latest for everything. If you push three times today, you lose track of what's actually deployed.


Pull and Deploy on VPS

First-Time Setup

SSH into your VPS, create a .env file for registry credentials:

# /opt/myapp/.env
REGISTRY_USER=your-username
REGISTRY_TOKEN=your-deploy-token-value

docker-compose.yaml

Reference the GitLab registry image directly:

services:
  web:
    image: registry.gitlab.com/your-username/my-django-app:v1.0.0
    container_name: myapp
    restart: unless-stopped
    ports:
      - "8000:8000"
    env_file:
      - .env
    volumes:
      - static_files:/app/static
      - media_files:/app/media
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/')"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  static_files:
  media_files:

Authenticate on VPS

echo "$REGISTRY_TOKEN" | docker login registry.gitlab.com -u "$REGISTRY_USER" --password-stdin

Pull and Restart

docker compose pull web
docker compose up -d

Deploy Script

Create a script on your VPS to automate pulls:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/opt/myapp"
IMAGE="registry.gitlab.com/your-username/my-django-app"

cd "$APP_DIR"

# Authenticate
echo "$REGISTRY_TOKEN" | docker login registry.gitlab.com -u "$REGISTRY_USER" --password-stdin

# Pull latest
docker compose pull web

# Restart with zero downtime (rolling)
docker compose up -d --remove-orphans

# Clean dangling images
docker image prune -f

echo "Deploy complete: $(date)"

Make it executable: chmod +x deploy.sh

Run from your dev machine after pushing a new image:

ssh vps "REGISTRY_TOKEN=xxx /opt/myapp/deploy.sh"

Or better, store the token in the VPS environment and just call:

ssh vps "/opt/myapp/deploy.sh"

Cleanup Policy

Without cleanup, your registry fills up with old tags. GitLab's cleanup policy auto-deletes tags matching your rules.

Setup

  1. Settings → Packages and registries
  2. Scroll to the container registry cleanup section
  3. Enable the cleanup policy and configure:
    • Keep the most recent: 5 (retain last 5 tags)
    • Expiration interval: 90 days
    • Keep tags matching: ^v[0-9]+ (keep semver tags)
    • Remove tags matching: .* (delete everything else by default)

Note: GitLab has reorganized packages and registry settings a few times, so exact labels can shift slightly between versions — if you don't see this under Settings → Packages and registries, check your GitLab version's changelog for the current path.

Regex Examples

Pattern Meaning
^v[0-9]+\.[0-9]+\.[0-9]+$ Keep only semver releases
^main-|^release- Keep main and release branch builds
^[a-f0-9]{7}$ Keep commit SHA tags

The policy runs on a recurring schedule. On GitLab.com, execution time per run is limited — if you have hundreds of tags, it may take multiple runs to clean everything.


Gotchas

Token Expiry

Container registry authentication tokens are short-lived by design. On GitLab.com, they expire after 15 minutes. On self-managed instances, the default is 5 minutes. If you're pushing large images and the upload takes longer than that window, the push fails partway through with an authentication error. Solutions:

  • Keep images small (multi-stage builds)
  • For self-managed instances, an admin can increase the duration in Admin Area → Settings → CI/CD → Container Registry
  • For GitLab.com, there's no user-facing way to extend the 15-minute window — shrinking the image is your main lever

Image Name Must Match Project Path

registry.gitlab.com/your-username/my-project   # correct
registry.gitlab.com/other-username/my-project  # wrong — won't work

The registry path is locked to the project path. Rename the project to change it.

No Storage Limit, But Be Reasonable

GitLab doesn't enforce a container registry storage cap on the free tier — this storage type sits outside the 10 GiB Git/LFS quota entirely. But if you're pushing 500 MB images daily without cleanup, you'll notice slower pulls and the web UI gets sluggish. Use cleanup policies.

Cleanup Policy Limitations

  • On GitLab.com, cleanup execution has a time limit per run
  • Some tags may survive the first run — they get cleaned on the next scheduled run
  • Protected tags and latest are never deleted by cleanup policies

Moving Projects

If you rename a project, the registry path changes. Old image references break. Clean up images before renaming, or accept that old tags become orphaned.


Quick Reference

# Login
docker login registry.gitlab.com

# Build
docker build -t registry.gitlab.com/USER/PROJECT:TAG .

# Push
docker push registry.gitlab.com/USER/PROJECT:TAG

# Pull (on VPS)
docker pull registry.gitlab.com/USER/PROJECT:TAG

# List images in browser
# Deploy → Container Registry (in GitLab project sidebar)

# Delete tag via API
curl --request DELETE \
  --header "PRIVATE-TOKEN: <token>" \
  "https://gitlab.com/api/v4/projects/PROJECT_ID/registry/repositories/REPO_ID/tags/TAG_NAME"

Summary

Feature Free Tier
Container storage Unlimited
Git repo storage 10 GiB
CI/CD minutes 400/month
Private repos Yes
Cleanup policies Yes
Web UI Yes
OCI conformance Yes
Multi-arch Yes

For a solo developer running containers on a VPS, GitLab's free container registry checks every box: zero cost, private images, standard tooling that works with Docker or Podman, and automatic cleanup. No need to maintain your own registry. No vendor lock-in. Just push and pull.