There is a satisfying moment when a folder on your laptop becomes somewhere the team can meet. It is also an easy moment to rush: one command can create the project, but it can just as quickly put it in your personal namespace, expose it with the wrong visibility, or publish a secret you never meant to share.

We will take an existing folder called weather-station from local history to a GitLab project, first with GitLab’s glab CLI and then with the REST API for automation. The remote operations below are documentation-validated examples—not claims that we created a project in your account. Read the placeholders as decisions you must make, not text to paste unchanged.

Before creation, decide who should own tomorrow

  • Namespace: choose your user only for personal work; choose the intended group or subgroup when a team should own permissions, runners, policies, and the URL.

  • Visibility: private, internal, and public have organizational consequences. Do not infer the right setting from the source folder or deployed application.

  • Project path: keep the URL path stable and readable. Renaming it later also changes clone URLs.

  • First branch: a default branch does not really exist until the repository has a branch. Group and instance settings may choose a name other than main.

  • Authentication: use glab auth login, an SSH key, or an appropriately scoped token. Never paste a token into a remote URL or commit it to a script.

Let glab learn the GitLab account you intend to use

Terminalbash
glab --version
glab auth login --hostname gitlab.com
glab auth status --hostname gitlab.com
<interactive authentication prompts>
<authenticated account and host status>

Risk level: caution. Review the command before running it.

Stop if the account or host feels unfamiliar

  • glab auth login stores credentials using the CLI’s supported authentication flow; follow its prompts rather than placing a token in shell history.

  • --hostname matters for self-managed GitLab. A project created on gitlab.com is not the same project as one created on your company instance.

  • glab auth status is a preflight check. Review the reported host and user before allowing a state-changing create command.

  • Authentication proves identity, not permission to create in every group.

Create the remote from inside the folder

weather-stationbash
git status --short --branch
glab repo create weather-station --private --skipGitInit --description "Sensor ingestion service"
<local branch and working-tree status>
<new GitLab project URL>

Risk level: caution. Review the command before running it.

The quiet details hidden in that creation command

  • glab repo create [path] is the current GitLab CLI syntax. The positional path names the new project; it is not a filesystem upload instruction.

  • --private makes the visibility choice explicit. Current CLI flags also include --internal and --public; select one deliberately rather than relying on a default.

  • --skipGitInit tells glab to create only the remote project here. That keeps local initialization and remote configuration visible in the later Git steps.

  • --description becomes project metadata. It does not create a README or document setup for the team.

  • This operation changes remote state. If it times out, check GitLab before retrying—an uncertain response can still have created the project.

Put a team-owned project in the team namespace

weather-stationbash
glab repo create platform/weather-station --private --skipGitInit --description "Sensor ingestion service"
<new project URL in the platform namespace>

Risk level: caution. Review the command before running it.

Ownership deserves one extra breath

  • GitLab documents both a simple path such as my-project and a namespaced path such as glab-cli/my-project.

  • For more explicit selection, the CLI also provides --group; inspect glab repo create --help for the installed version before automating flags.

  • Use the actual group or subgroup path, respecting access and instance policy. Similar display names do not guarantee the same namespace.

  • A group destination lets ownership outlive one employee’s account, but it also applies group-level visibility, branch, CI, and compliance settings.

Join the local history to the new home

weather-stationbash
git remote -v
git remote add origin git@gitlab.com:platform/weather-station.git
git push --set-upstream origin HEAD
<no output when no remote exists>
<push negotiation and new branch>
branch '<current-branch>' set up to track 'origin/<current-branch>'

Risk level: caution. Review the command before running it.

Why HEAD is kinder than assuming main

  • git remote -v may reveal an existing origin. If one exists, inspect it; do not run remote add again or overwrite it blindly.

  • Copy the SSH or HTTPS clone URL from the created project. Replace both the namespace and project placeholders.

  • HEAD means the branch currently checked out. --set-upstream connects it to the new remote branch for later git push and git pull defaults.

  • The first pushed branch commonly becomes the default when the project is empty, subject to GitLab group and instance default-branch settings. Verify it in the project rather than assuming.

  • SSH uses an account key. For HTTPS, let a credential helper handle a token; embedding it in the URL can expose it in configuration and history.

Ask GitLab what it received

weather-stationbash
glab repo view platform/weather-station
git remote get-url origin
git branch -vv
<project metadata>
git@gitlab.com:platform/weather-station.git
* <branch> <commit> [origin/<branch>] <message>

A URL alone is not the finish line

  • glab repo view confirms the project resolved for the authenticated user; inspect namespace and visibility in its metadata.

  • git remote get-url origin confirms where later pushes go, but not that the remote accepted the expected commit.

  • git branch -vv shows upstream tracking. Also open GitLab and verify the visible branch, commit, files, members, and default branch.

  • Create a small feature branch and merge request before announcing handoff; that exercise reveals branch rules and CI behavior more honestly than an empty project page.

Use the Projects API when creation belongs in automation

Terminalbash
read -rsp "GitLab token: " GITLAB_TOKEN; echo
GITLAB_URL=https://gitlab.example.com
curl --fail-with-body --silent --show-error --request POST \
  --url "$GITLAB_URL/api/v4/projects" \
  --config - \
  --data-urlencode "name=Weather Station" \
  --data-urlencode "path=weather-station" \
  --data-urlencode "namespace_id=123456" \
  --data-urlencode "visibility=private" <<EOF
header = "PRIVATE-TOKEN: $GITLAB_TOKEN"
EOF
unset GITLAB_TOKEN
<JSON representation of the created project>

Risk level: caution. Review the command before running it.

What the API needs—and what it refuses to guess

  • POST /projects accepts name or path; supplying both makes the display name and URL path intentional.

  • namespace_id is the numeric ID of the target namespace, not its visible path. If omitted, GitLab creates the project in the authenticated user’s namespace.

  • --data-urlencode safely encodes form values. It does not validate that the namespace ID or visibility is organizationally correct.

  • The PRIVATE-TOKEN header is GitLab’s documented PAT mechanism. Feeding curl configuration over standard input avoids storing the token in the command line or script, though privileged local inspection and shell memory still deserve consideration.

  • --fail-with-body returns a failure status for HTTP errors while preserving GitLab’s JSON error body for diagnostics. Redact tokens and sensitive paths before logging.

  • Use the narrowest suitable token scope, protect it in a CI secret store, rotate it, and unset interactive variables promptly.

Make repeated automation converge instead of collide

ensure-gitlab-project.shbash
#!/usr/bin/env bash
set -euo pipefail
 
: "${GITLAB_URL:?Set the GitLab base URL}"
: "${GITLAB_TOKEN:?Load the token from a protected secret store}"
 
project_path='platform/weather-station'
encoded_path=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$project_path")
 
status=$(curl --silent --output project.json --write-out '%{http_code}' \
  --url "$GITLAB_URL/api/v4/projects/$encoded_path" \
  --config - <<EOF
header = "PRIVATE-TOKEN: $GITLAB_TOKEN"
EOF
)
 
case "$status" in
  200) printf 'Project already exists: %s\n' "$project_path" ;;
  404) printf 'Project is absent; a reviewed create step may proceed.\n' ;;
  *)   printf 'Lookup failed with HTTP %s; refusing to create.\n' "$status" >&2; exit 1 ;;
esac

Check the URL-encoded project path before creating; fail closed on ambiguous API responses.

Idempotence begins with refusing uncertainty

  • The project lookup endpoint accepts a numeric ID or a URL-encoded namespace/project path; the slash must be encoded as %2F.

  • set -euo pipefail catches several shell mistakes, but it cannot decide whether an existing project has the desired owner, visibility, or settings. Parse and compare the returned JSON before treating it as compliant.

  • 200 establishes visibility to this token; 404 can mean absent or not visible to the caller. Creation still requires permission and a reviewed destination.

  • Unexpected authentication, authorization, rate-limit, or server responses stop the script. A reliable pipeline should not reinterpret every failure as “please create.”

  • project.json can contain sensitive metadata. Store it in a secured temporary workspace or remove it according to the runner’s retention policy.

When GitLab says no, preserve the clue

  • 401 Unauthorized: the credential is absent, invalid, expired, or not being sent to the intended host. Re-authenticate; do not print the token.

  • 403 Forbidden: identity was recognized but lacks permission, or policy blocks the action. Check the target group, role, token scope, and administrator rules.

  • A validation error: inspect GitLab’s JSON message for an invalid path, visibility, namespace, or parameter. Status details can vary by GitLab version, so preserve the body rather than scripting against a guessed phrase.

  • Name or path already taken: query the exact namespace/path. Reuse only after proving it is the intended project; otherwise choose a truthful new path.

  • `remote origin already exists`: run git remote -v; if it is wrong, use git remote set-url origin <exact-clone-url> after review.

  • Push denied to a protected branch: do not weaken protection reflexively. Push a feature branch, open a merge request, and follow the team’s approval and CI policy.

Hand off something kinder than an empty page

  • Confirm the project lives in the durable team namespace with the intended visibility.

  • Set or verify the default branch only after the first push, then review protected-branch rules and who may merge or push.

  • Invite people through GitLab roles; never share a token or SSH private key.

  • Add a README that explains purpose, setup, test commands, ownership, and the next meaningful task.

  • Let CI run a genuine project check. A decorative green pipeline teaches the team to distrust green.

  • Test cloning and a merge request from a teammate-level account when possible. Owners and administrators can accidentally bypass the friction everyone else will meet.

Official references and the next useful steps