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
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 loginstores credentials using the CLI’s supported authentication flow; follow its prompts rather than placing a token in shell history.--hostnamematters for self-managed GitLab. A project created ongitlab.comis not the same project as one created on your company instance.glab auth statusis 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
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.--privatemakes the visibility choice explicit. Current CLI flags also include--internaland--public; select one deliberately rather than relying on a default.--skipGitInittellsglabto create only the remote project here. That keeps local initialization and remote configuration visible in the later Git steps.--descriptionbecomes 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
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-projectand a namespaced path such asglab-cli/my-project.For more explicit selection, the CLI also provides
--group; inspectglab repo create --helpfor 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
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 -vmay reveal an existingorigin. If one exists, inspect it; do not runremote addagain or overwrite it blindly.Copy the SSH or HTTPS clone URL from the created project. Replace both the namespace and project placeholders.
HEADmeans the branch currently checked out.--set-upstreamconnects it to the new remote branch for latergit pushandgit pulldefaults.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
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 viewconfirms the project resolved for the authenticated user; inspect namespace and visibility in its metadata.git remote get-url originconfirms where later pushes go, but not that the remote accepted the expected commit.git branch -vvshows 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
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 /projectsacceptsnameorpath; supplying both makes the display name and URL path intentional.namespace_idis the numeric ID of the target namespace, not its visible path. If omitted, GitLab creates the project in the authenticated user’s namespace.--data-urlencodesafely encodes form values. It does not validate that the namespace ID or visibility is organizationally correct.The
PRIVATE-TOKENheader 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-bodyreturns 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
#!/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 ;;
esacCheck 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 pipefailcatches 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.200establishes visibility to this token;404can 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.jsoncan 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, usegit 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.
Comments and corrections